InfoGrab DocsInfoGrab Docs

마이그레이션 스타일 가이드

요약

GitLab용 마이그레이션을 작성할 때는 이 마이그레이션이 크고 작은 수십만 개 조직에서 실행되며, 일부 조직은 데이터베이스에 수년간 쌓인 데이터를 보유하고 있다는 점을 고려해야 합니다. 또한 규모와 관계없이 업그레이드를 위해 서버를 오프라인으로 전환해야 하는 것은 대부분의 조직에 큰 부담입니다.

GitLab용 마이그레이션을 작성할 때는 이 마이그레이션이 크고 작은 수십만 개 조직에서 실행되며, 일부 조직은 데이터베이스에 수년간 쌓인 데이터를 보유하고 있다는 점을 고려해야 합니다.

또한 규모와 관계없이 업그레이드를 위해 서버를 오프라인으로 전환해야 하는 것은 대부분의 조직에 큰 부담입니다. 따라서 마이그레이션은 신중하게 작성해야 하고, 온라인 상태에서 적용할 수 있어야 하며, 아래 스타일 가이드를 준수해야 합니다.

마이그레이션은 GitLab 설치를 오프라인으로 전환하도록 요구해서는 안 됩니다. 마이그레이션은 항상 다운타임을 피하는 방식으로 작성해야 합니다. 과거에는 DOWNTIME 상수를 설정해 다운타임을 허용하는 마이그레이션을 정의하는 프로세스가 있었습니다. 오래된 마이그레이션에서 이를 볼 수 있습니다. 이 프로세스는 4년 동안 유지되었지만 한 번도 사용된 적이 없으며, 그 결과 다운타임을 피하도록 마이그레이션을 다르게 작성하는 방법을 항상 찾을 수 있다는 점을 알게 되었습니다.

마이그레이션을 작성할 때는 데이터베이스에 오래된 데이터나 불일치가 있을 수 있다는 점도 고려하고 이에 대비합니다. 데이터베이스 상태에 대한 가정은 가능한 한 적게 합니다.

GitLab 전용 코드는 이후 버전에서 바뀔 수 있으므로 여기에 의존하지 않습니다. 필요한 경우 GitLab 코드를 마이그레이션에 복사해 붙여 넣어, 이후 버전에서도 호환되도록 합니다.

적절한 마이그레이션 유형 선택#

새 마이그레이션을 추가하기 전 첫 단계는 어떤 유형이 가장 적합한지 결정하는 것입니다.

현재 만들 수 있는 마이그레이션은 수행해야 하는 작업의 종류와 완료까지 걸리는 시간에 따라 세 가지입니다.

  1. 일반 스키마 마이그레이션. 새 애플리케이션 코드가 배포되기 전에 실행되는 db/migrate의 전통적인 Rails 마이그레이션입니다 (GitLab.com에서는 Canary가 배포되기 전). 따라서 배포를 불필요하게 지연시키지 않도록 비교적 빨라야 하며, 길어도 몇 분을 넘지 않아야 합니다.

    더 오래 걸리더라도 애플리케이션이 올바르게 동작하는 데 반드시 필요한 마이그레이션은 예외입니다. 예를 들어 고유한 튜플을 강제하는 인덱스나, 애플리케이션의 핵심 부분에서 쿼리 성능에 필요한 인덱스가 여기에 해당할 수 있습니다. 그러나 마이그레이션이 허용할 수 없을 만큼 느린 경우에는 기능 플래그로 해당 기능을 보호하고 대신 배포 후 마이그레이션을 수행하는 편이 나을 수 있습니다. 그러면 마이그레이션이 끝난 뒤에 기능을 켤 수 있습니다.

    새 모델을 추가하는 마이그레이션도 이러한 일반 스키마 마이그레이션에 포함됩니다. 차이점은 마이그레이션 생성에 사용하는 Rails 명령과, 모델용 파일 하나와 모델의 스펙용 파일 하나가 추가로 생성된다는 점뿐입니다.

  2. 배포 후 마이그레이션. db/post_migrate에 있는 Rails 마이그레이션이며 GitLab.com 배포와는 독립적으로 실행됩니다. 대기 중인 배포 후 마이그레이션은 릴리스 매니저의 재량에 따라 배포 후 마이그레이션 파이프라인을 통해 매일 실행됩니다. 이러한 마이그레이션은 애플리케이션 동작에 필수적이지 않은 스키마 변경이나 길어야 몇 분 걸리는 데이터 마이그레이션에 사용할 수 있습니다. 배포 후에 실행해야 하는 스키마 변경의 일반적인 예는 다음과 같습니다.

    • 사용하지 않는 칼럼 제거와 같은 정리 작업
    • 트래픽이 많은 테이블에 필수적이지 않은 인덱스 추가
    • 생성에 오래 걸리는, 필수적이지 않은 인덱스 추가

    애플리케이션 동작에 필수적인 스키마 변경에는 이러한 마이그레이션을 사용하지 않아야 합니다. 그러한 스키마 변경을 배포 후 마이그레이션으로 수행하면 과거에 문제가 발생한 적이 있습니다(예: 이 이슈). 항상 일반 스키마 마이그레이션으로 수행해야 하며 배포 후 마이그레이션으로 실행해서는 안 되는 변경은 다음과 같습니다.

    • 새 테이블 생성. 예: create_table
    • 기존 테이블에 새 칼럼 추가. 예: add_column

    [!note] 배포 후 마이그레이션(Post-deployment migration)은 흔히 PDM으로 줄여 부릅니다.

  3. 배치 백그라운드 마이그레이션. 일반 Rails 마이그레이션이 아니라 Sidekiq job으로 실행되는 애플리케이션 코드입니다. 다만 이를 예약하는 데는 배포 후 마이그레이션을 사용합니다. 배포 후 마이그레이션의 시간 가이드라인을 초과하는 데이터 마이그레이션에만 사용합니다. 배치 백그라운드 마이그레이션은 스키마를 변경해서는 안 됩니다.

다음 다이어그램을 참고해 결정하되, 이는 하나의 도구일 뿐이며 최종 결과는 항상 적용하려는 구체적인 변경 사항에 따라 달라진다는 점을 유념합니다.

Mermaid 다이어그램 (13줄)
소스 코드 보기
graph LR
    A{Schema<br/>changed?}
    A -->|Yes| C{Critical to<br/>speed or<br/>behavior?}
    A -->|No| D{Is it fast?}
C --&gt;|Yes| H{Is it fast?}
C --&gt;|No| F[Post-deploy migration]

H --&gt;|Yes| E[Regular migration]
H --&gt;|No| I[Post-deploy migration&lt;br/&gt;+ feature flag]

D --&gt;|Yes| F[Post-deploy migration]
D --&gt;|No| G[Background migration]</code></pre></details></div>

데이터베이스 인덱스를 추가할 때 사용할 마이그레이션 유형을 고르는 방법은 사용할 마이그레이션 유형도 참고합니다.

마이그레이션에 걸리는 시간#

일반적으로 GitLab.com에서 한 번의 배포에 포함되는 모든 마이그레이션은 1시간을 넘지 않아야 합니다. 다음 가이드라인은 엄격한 규칙이 아니며, 마이그레이션 소요 시간을 최소한으로 유지하기 위해 추정한 값입니다.

Note

모든 소요 시간은 GitLab.com을 기준으로 측정해야 한다는 점을 유념합니다.

데이터베이스 마이그레이션 파이프라인의 결과에는 마이그레이션의 소요 시간 정보가 포함됩니다.

동시 작업과 백그라운드 마이그레이션을 포함한 쿼리 수준의 시간 제한은 쿼리 성능 가이드라인을 참고합니다.

마이그레이션 유형 권장 소요 시간 비고
일반 마이그레이션 <= 3 minutes 이를 지연할 수 없고, 이것이 없으면 애플리케이션 기능이나 성능이 심각하게 저하되는 변경은 유효한 예외입니다.
배포 후 마이그레이션 <= 10 minutes 스키마 변경은 백그라운드 마이그레이션에서 수행해서는 안 되므로 유효한 예외입니다. 인덱스 생성과 같은 동시 작업에는 별도의 20 minute 제한이 있습니다.
백그라운드 마이그레이션 > 10 minutes 이 마이그레이션은 더 큰 테이블에 적합하므로 정확한 시간 가이드라인을 정할 수 없습니다. 다만 개별 쿼리는 콜드 캐시 상태에서 1 second 실행 시간 미만이어야 합니다.

db:gitlabcom-database-testing 파이프라인이 인덱스 생성에 20분 넘게 걸린다고 보고하면 인덱스를 비동기로 생성합니다. 테스트 파이프라인은 데이터베이스 복제본에서 실행되므로 실제 GitLab.com 실행 시간을 낮게 추정할 수 있으며, 이 때문에 이 기준값은 의도적으로 보수적으로 잡았습니다.

대용량 테이블 제한 사항#

크기 임계값을 초과하는 테이블에 새 칼럼이나 인덱스를 추가하기 전에 대용량 테이블 제한 사항을 읽습니다.

대상 데이터베이스 결정#

GitLab은 main과 ci라는 두 개의 서로 다른 Postgres 데이터베이스에 연결합니다. 마이그레이션은 이 두 데이터베이스 중 한쪽 또는 양쪽에서 실행될 수 있으므로 이 분리가 마이그레이션에 영향을 줄 수 있습니다.

추가하는 마이그레이션에서 이를 고려해야 하는지, 그리고 어떻게 고려해야 하는지는 여러 데이터베이스를 위한 마이그레이션을 읽고 파악합니다.

일반 스키마 마이그레이션 생성#

마이그레이션을 만들려면 다음 Rails 생성기를 사용할 수 있습니다.

bundle exec rails g migration migration_name_here

이 명령은 db/migrate에 마이그레이션 파일을 생성합니다.

새 모델을 추가하는 일반 스키마 마이그레이션#

새 모델을 만들려면 다음 Rails 생성기를 사용할 수 있습니다.

bundle exec rails g model model_name_here

이 명령은 다음을 생성합니다.

  • db/migrate의 마이그레이션 파일
  • app/models의 모델 파일
  • spec/models의 스펙 파일

스키마 변경#

스키마 변경 사항은 db/structure.sql에 커밋해야 합니다. 이 파일은 bundle exec rails db:migrate를 실행할 때 Rails가 자동으로 생성하므로, 일반적으로 직접 편집하지 않아야 합니다. 마이그레이션이 테이블에 칼럼을 추가하면 해당 칼럼은 마이그레이션에 의해 그 테이블 스키마의 끝에 추가됩니다. 기존 테이블의 칼럼 순서를 수동으로 바꾸지 않습니다. 순서를 바꾸면 Rails가 생성한 db/structure.sql을 사용하는 다른 사람들과 충돌이 발생합니다.

Note

인덱스를 비동기로 생성하려면 머지 리퀘스트가 두 개 필요합니다. 작업이 끝나면 add_concurrent_index로 인덱스를 추가하는 머지 리퀘스트에 스키마 변경 사항을 커밋합니다.

GDK의 로컬 데이터베이스가 main의 스키마와 달라진 경우에는 스키마 변경 사항을 Git에 깔끔하게 커밋하기 어려울 수 있습니다. 이때는 scripts/regenerate-schema 스크립트를 사용해 추가하는 마이그레이션에 맞는 깨끗한 db/structure.sql을 다시 생성할 수 있습니다. 이 스크립트는 db/migrate 또는 db/post_migrate에 있는 모든 마이그레이션을 적용하므로, 스키마에 커밋하지 않으려는 마이그레이션이 있다면 이름을 바꾸거나 제거합니다. 브랜치가 기본 Git 브랜치를 대상으로 하지 않는 경우 TARGET 환경 변수를 설정할 수 있습니다.

# Regenerate schema against `main`
scripts/regenerate-schema

# Regenerate schema against `12-9-stable-ee`
TARGET=12-9-stable-ee scripts/regenerate-schema

scripts/regenerate-schema 스크립트가 추가 차이를 만들 수 있습니다. 이 경우 아래의 수동 절차를 사용합니다. 여기서 <migration ID>는 마이그레이션 파일의 DATETIME 부분입니다.

# Rebase against master
git rebase master

# Rollback changes
VERSION=<migration ID> bundle exec rails db:migrate:down:main

# Checkout db/structure.sql from master
git checkout origin/master db/structure.sql

# Migrate changes
VERSION=<migration ID> bundle exec rails db:migrate:main

테이블을 생성한 후에는 데이터베이스 딕셔너리 가이드에 나온 단계에 따라 데이터베이스 딕셔너리에 추가해야 합니다.

마이그레이션 체크섬 파일#

마이그레이션이 처음 실행되면 마이그레이션의 타임스탬프로 생성한 SHA256이 담긴 새 migration checksum file이 db/schema_migrations에 만들어집니다. 이 새 파일의 이름은 마이그레이션 파일 이름의 타임스탬프 부분과 같습니다. 예를 들어 db/schema_migrations/20241021120146입니다. 이 파일의 내용은 타임스탬프 부분의 SHA256입니다. 예를 들면 다음과 같습니다.

$ echo -n "20241021120146" | sha256sum
7a3e382a6e5564bfa7004bca1a357a910b151e7399c6466113daf01526d97470  -

SHA256은 파일에 고유한 내용을 더해, Git의 이름 변경 감지가 이 파일들을 별개의 파일로 인식하게 합니다.

이 migration checksum file은 마이그레이션이 성공적으로 실행되어 그 결과가 db/structure.sql에 기록되었음을 나타냅니다. 이 파일이 있으면 같은 마이그레이션이 두 번 실행되지 않으므로, 새 마이그레이션을 추가하는 머지 리퀘스트에 이 파일을 반드시 포함해야 합니다.

db/schema_migrations 디렉터리에 대한 자세한 내용은 Development change: Database schema version handling outside of structure.sql을 참고합니다.

마이그레이션 체크섬 파일을 최신 상태로 유지#

  • 새 마이그레이션을 만들면 rake db:migrate를 실행해 마이그레이션을 실행하고 이에 해당하는 db/schema_migration/<timestamp> 체크섬 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.
  • 마이그레이션을 삭제하면 이에 해당하는 db/schema_migration/<timestamp> 체크섬 파일을 제거합니다.
  • 마이그레이션의 _타임스탬프 부분_이 바뀌면 이에 해당하는 db/schema_migration/<timestamp> 체크섬 파일을 제거하고 rake db:migrate를 실행해 새 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.
  • 마이그레이션의 내용이 바뀌어도 db/schema_migration/<timestamp> 체크섬 파일은 변경할 필요가 없습니다.

다운타임 방지#

"마이그레이션에서 다운타임 방지" 문서는 다음과 같은 다양한 데이터베이스 작업을 설명합니다.

또한 다운타임 없이 이러한 작업을 수행하는 방법도 설명합니다.

되돌릴 수 있는 마이그레이션#

마이그레이션은 되돌릴 수 있어야 합니다. 취약점이나 버그가 발생했을 때 다운그레이드할 수 있어야 하므로 이는 매우 중요합니다.

Note

GitLab 프로덕션 환경에서는 문제가 발생하면 db:rollback으로 마이그레이션을 롤백하는 대신 롤포워드 전략을 사용합니다. GitLab Self-Managed에서는 업그레이드 프로세스를 시작하기 전에 만든 백업을 복원할 것을 권장합니다. down 메서드는 주로 개발 환경에서 사용합니다. 예를 들어 개발자가 커밋이나 브랜치를 전환할 때 로컬의 structure.sql 파일 사본과 데이터베이스가 일관된 상태인지 확인하려는 경우입니다.

마이그레이션에는 되돌릴 수 있는지를 어떻게 테스트했는지 설명하는 주석을 추가합니다.

일부 마이그레이션은 되돌릴 수 없습니다. 예를 들어 일부 데이터 마이그레이션은 마이그레이션 이전 데이터베이스 상태에 대한 정보가 사라지므로 되돌릴 수 없습니다. 이 경우에도 up 메서드가 수행한 변경을 되돌릴 수 없는 이유를 설명하는 주석과 함께 down 메서드를 만들어, 마이그레이션 중에 수행된 변경은 되돌릴 수 없더라도 마이그레이션 자체는 되돌릴 수 있도록 해야 합니다.

def down
  # no-op

  # comment explaining why changes performed by `up` cannot be reversed.
end

이러한 마이그레이션은 본질적으로 위험하며, 리뷰를 위해 마이그레이션을 준비할 때 추가 조치가 필요합니다.

원자성과 트랜잭션#

기본적으로 마이그레이션은 단일 트랜잭션입니다. 트랜잭션은 마이그레이션 시작 시 열리고 모든 단계가 처리된 후 커밋됩니다.

마이그레이션을 단일 트랜잭션으로 실행하면 단계 중 하나가 실패할 경우 어떤 단계도 실행되지 않으므로 데이터베이스가 유효한 상태로 유지됩니다. 따라서 다음 중 하나를 따릅니다.

  • 모든 마이그레이션을 하나의 단일 트랜잭션 마이그레이션에 넣습니다.
  • 필요한 경우 대부분의 작업을 하나의 마이그레이션에 넣고, 단일 트랜잭션으로 수행할 수 없는 단계는 별도의 마이그레이션으로 만듭니다.

예를 들어 빈 테이블을 만들고 이 테이블의 인덱스를 생성해야 한다면 일반적인 단일 트랜잭션 마이그레이션과 Rails의 기본 스키마 구문인 add_index를 사용해야 합니다. 이 작업은 차단 작업이지만 테이블이 아직 사용되지 않아 레코드가 하나도 없으므로 문제가 되지 않습니다.

Note

일반적으로 서브트랜잭션은 허용되지 않습니다. 필요한 경우 단일 트랜잭션의 무거운 작업에 설명된 대로 여러 개의 별도 트랜잭션을 사용합니다.

단일 트랜잭션의 무거운 작업#

단일 트랜잭션 마이그레이션을 사용하면 트랜잭션이 마이그레이션이 진행되는 동안 데이터베이스 연결을 점유하므로, 마이그레이션의 작업이 너무 오래 걸리지 않도록 해야 합니다. 일반적으로 트랜잭션은 빠르게 실행되어야 합니다. 이를 위해 마이그레이션에서 실행하는 각 쿼리에 최대 쿼리 시간 제한을 지킵니다.

단일 트랜잭션 마이그레이션이 끝나기까지 오래 걸리는 경우 몇 가지 선택지가 있습니다. 어느 경우든 마이그레이션에 걸리는 시간에 따라 적절한 마이그레이션 유형을 선택해야 합니다.

  • 마이그레이션을 여러 개의 단일 트랜잭션 마이그레이션으로 나눕니다.

  • disable_ddl_transaction! 사용으로 여러 트랜잭션을 사용합니다.

  • 구문 타임아웃과 잠금 타임아웃 설정을 조정한 후 단일 트랜잭션 마이그레이션을 계속 사용합니다. 무거운 워크로드가 트랜잭션의 보장을 사용해야 한다면 마이그레이션이 타임아웃 제한에 걸리지 않고 실행되는지 확인해야 합니다. 같은 조언이 단일 트랜잭션 마이그레이션과 개별 트랜잭션 모두에 적용됩니다.

    • 구문 타임아웃: 구문 타임아웃은 GitLab.com 프로덕션 데이터베이스에서 15s로 설정되어 있지만 인덱스 생성에는 종종 15초보다 오래 걸립니다. add_concurrent_index를 비롯한 기존 헬퍼를 사용하면 필요에 따라 구문 타임아웃이 자동으로 해제됩니다. 드물게는 disable_statement_timeout 사용으로 타임아웃 제한을 직접 설정해야 할 수도 있습니다.
Note

마이그레이션을 실행할 때는 statement_timeout, lock_wait_timeout 같은 설정을 제어하기 위해 PgBouncer를 거치지 않고 기본 데이터베이스에 직접 연결합니다.

구문 타임아웃 제한을 일시적으로 해제#

마이그레이션 헬퍼 disable_statement_timeout을 사용하면 구문 타임아웃을 트랜잭션별 또는 연결별로 일시적으로 0으로 설정할 수 있습니다.

  • CREATE INDEX CONCURRENTLY처럼 구문이 명시적 트랜잭션 안에서 실행되는 것을 지원하지 않는 경우에는 연결별 옵션을 사용합니다.
  • ALTER TABLE ... VALIDATE CONSTRAINT처럼 구문이 명시적 트랜잭션 블록을 지원하는 경우에는 트랜잭션별 옵션을 사용해야 합니다.

대부분의 마이그레이션 헬퍼가 필요할 때 이미 내부적으로 이를 사용하므로 disable_statement_timeout을 사용할 일은 드뭅니다. 예를 들어 인덱스 생성에는 보통 15초보다 오래 걸리는데, 15초는 GitLab.com 프로덕션 데이터베이스에 설정된 기본 구문 타임아웃입니다. 헬퍼 add_concurrent_index는 disable_statement_timeout에 전달된 블록 안에서 인덱스를 생성하여 연결별로 구문 타임아웃을 해제합니다.

마이그레이션에서 원시 SQL 구문을 작성하는 경우 disable_statement_timeout을 직접 사용해야 할 수 있습니다. 이때는 데이터베이스 리뷰어 및 메인테이너와 상의합니다.

트랜잭션으로 감싼 마이그레이션 비활성화#

ActiveRecord 메서드인 disable_ddl_transaction!을 사용하면 마이그레이션을 단일 트랜잭션으로 실행하지 않도록 선택할 수 있습니다. 다른 데이터베이스 시스템에서는 이 메서드가 다른 이름으로 불리며 결과도 다를 수 있습니다. GitLab에서는 PostgreSQL만 사용합니다. disable_ddl_transaction!은 항상 다음과 같은 의미로 읽어야 합니다.

"이 마이그레이션을 단일 PostgreSQL 트랜잭션으로 실행하지 않습니다. PostgreSQL 트랜잭션은 필요할 때, 필요한 경우에만 직접 엽니다."

Note

명시적인 PostgreSQL 트랜잭션 .transaction(또는 BEGIN; COMMIT;)을 사용하지 않더라도 모든 SQL 구문은 여전히 트랜잭션으로 실행됩니다. PostgreSQL 트랜잭션 문서를 참고합니다.

GitLab에서는 disable_ddl_transaction!을 사용한 마이그레이션을 비트랜잭션 마이그레이션이라고 부르기도 했습니다. 이는 마이그레이션이 단일 트랜잭션으로 실행되지 않는다는 뜻일 뿐이었습니다.

disable_ddl_transaction!은 언제 사용해야 할까요? 대부분의 경우 기존 RuboCop 규칙이나 마이그레이션 헬퍼가 disable_ddl_transaction!을 사용해야 하는지 감지할 수 있습니다. 마이그레이션에서 disable_ddl_transaction!을 사용해야 할지 확신이 서지 않으면 사용하지 않고, RuboCop 규칙과 데이터베이스 리뷰의 안내를 따릅니다.

PostgreSQL이 명시적 트랜잭션 밖에서 작업을 실행하도록 요구할 때 disable_ddl_transaction!을 사용합니다.

  • 이러한 작업의 가장 대표적인 예는 CREATE INDEX CONCURRENTLY 명령입니다. PostgreSQL은 차단 버전(CREATE INDEX)을 트랜잭션 안에서 실행하도록 허용합니다. CREATE INDEX와 달리 CREATE INDEX CONCURRENTLY는 트랜잭션 밖에서 실행해야 합니다. 따라서 마이그레이션이 CREATE INDEX CONCURRENTLY 구문 하나만 실행하더라도 disable_ddl_transaction!을 사용해 단일 트랜잭션 실행을 해제해야 합니다. 헬퍼 add_concurrent_index를 사용하려면 disable_ddl_transaction!이 필요한 것도 이 때문입니다. CREATE INDEX CONCURRENTLY는 일반적인 경우라기보다 예외에 가깝습니다.

어떤 이유로든 마이그레이션에서 여러 트랜잭션을 실행해야 할 때 disable_ddl_transaction!을 사용합니다. 여러 트랜잭션을 사용하는 대부분의 경우는 느린 트랜잭션 하나를 실행하는 것을 피하기 위해서입니다.

  • 예를 들어 많은 양의 데이터를 삽입, 업데이트 또는 삭제(DML)할 때는 배치로 수행해야 합니다. 배치마다 작업을 묶어야 한다면 배치를 처리할 때 트랜잭션 블록을 명시적으로 열 수 있습니다. 상당히 큰 워크로드에는 배치 백그라운드 마이그레이션 사용을 고려합니다.

마이그레이션 헬퍼가 요구할 때 disable_ddl_transaction!을 사용합니다. 여러 마이그레이션 헬퍼는 트랜잭션을 언제 어떻게 열지 정밀하게 제어해야 하므로 disable_ddl_transaction!과 함께 실행해야 합니다.

  • CREATE INDEX CONCURRENTLY와 달리 외래 키는 트랜잭션 안에서 추가_할 수 있습니다_. 그러나 PostgreSQL에는 CREATE INDEX CONCURRENTLY와 비슷한 옵션이 없습니다. 대신 헬퍼 add_concurrent_foreign_key가 자체 트랜잭션을 열어 소스 테이블과 대상 테이블을 잠급니다. 이렇게 하면 외래 키를 추가하고 검증하는 동안 잠금이 최소화됩니다.
  • 앞서 안내했듯이 확신이 서지 않으면 disable_ddl_transaction!을 사용하지 않고 RuboCop 검사를 위반하는지 확인합니다.

마이그레이션이 실제로 PostgreSQL 데이터베이스를 다루지 않거나 여러 PostgreSQL 데이터베이스를 다룰 때 disable_ddl_transaction!을 사용합니다.

  • 예를 들어 마이그레이션이 Redis 서버를 대상으로 할 수 있습니다. 원칙적으로 PostgreSQL 트랜잭션 안에서는 외부 서비스와 상호작용할 수 없습니다.
  • 트랜잭션은 하나의 데이터베이스 연결에 사용됩니다. 마이그레이션이 ci와 main 데이터베이스처럼 여러 데이터베이스를 대상으로 한다면 여러 데이터베이스를 위한 마이그레이션을 따릅니다.

명명 규칙#

데이터베이스 객체(테이블, 인덱스, 뷰 등)의 이름은 소문자여야 합니다. 이름을 소문자로 지으면 따옴표 없는 이름을 사용하는 쿼리에서 오류가 발생하지 않습니다.

칼럼 이름은 ActiveRecord의 스키마 규칙과 일관되게 유지합니다.

사용자 지정 인덱스 및 제약 조건 이름은 제약 조건 명명 규칙 가이드라인을 따라야 합니다.

긴 인덱스 이름 줄이기#

PostgreSQL은 칼럼이나 인덱스 이름 같은 식별자의 길이를 제한합니다. 칼럼 이름은 보통 문제가 되지 않지만 인덱스 이름은 더 길어지는 경향이 있습니다. 너무 긴 이름을 줄이는 몇 가지 방법은 다음과 같습니다.

  • index_ 대신 i_ 접두사를 붙입니다.
  • 중복되는 접두사를 생략합니다. 예를 들어 index_vulnerability_findings_remediations_on_vulnerability_remediation_id는 index_vulnerability_findings_remediations_on_remediation_id가 됩니다.
  • 칼럼 대신 index_users_for_unconfirmation_notification처럼 인덱스의 목적을 지정합니다.

마이그레이션 타임스탬프 경과 기간#

마이그레이션 파일 이름의 타임스탬프 부분은 마이그레이션이 실행되는 순서를 결정합니다. 다음 두 가지 사이에 대략적인 상관관계를 유지하는 것이 중요합니다.

  1. 마이그레이션이 GitLab 코드베이스에 추가된 시점
  2. 마이그레이션 자체의 타임스탬프

새 마이그레이션의 타임스탬프는 이전 필수 업그레이드 중단점보다 앞서서는 안 됩니다. 마이그레이션은 가끔 스쿼시되며, 타임스탬프가 이전 필수 중단점보다 앞선 마이그레이션이 추가되면 이슈 408304에서 발생한 것과 같은 문제가 생길 수 있습니다.

예를 들어 현재 GitLab 16.0을 대상으로 개발 중이라면 이전 필수 중단점은 15.11입니다. 15.11은 2023년 4월 23일에 릴리스되었습니다. 따라서 허용되는 최소 타임스탬프는 20230424000000입니다.

모범 사례#

위 내용은 엄격한 규칙으로 간주해야 하지만, 마지막 필수 중단점 이후 얼마나 시간이 지났는지와 관계없이 마이그레이션 타임스탬프를 업스트림에 머지될 것으로 예상되는 날짜로부터 3주 이내로 유지하는 것이 모범 사례입니다.

마이그레이션 타임스탬프를 업데이트하려면 다음을 수행합니다.

  1. ci와 main 데이터베이스에서 마이그레이션을 다운 마이그레이션합니다.

    rake db:migrate:down:main VERSION=<timestamp>
    rake db:migrate:down:ci VERSION=<timestamp>
    
  2. 마이그레이션 파일을 삭제합니다.

  3. 마이그레이션 스타일 가이드에 따라 마이그레이션을 다시 만듭니다.

또는 다음 스크립트를 사용해 모든 마이그레이션 타임스탬프를 새로 고칠 수 있습니다.

scripts/refresh-migrations-timestamps

이 스크립트는 다음을 수행합니다.

  1. 모든 마이그레이션 타임스탬프를 현재 시각으로 업데이트합니다.
  2. 마이그레이션의 상대적인 순서를 유지합니다.
  3. 파일 이름과 마이그레이션 클래스 내부의 타임스탬프를 모두 업데이트합니다.
  4. 일반 마이그레이션과 배포 후 마이그레이션을 모두 처리합니다.
Note

마이그레이션이 오랫동안(3주 초과) 리뷰 중이거나 오래된 마이그레이션 브랜치를 리베이스할 때는 머지하기 전에 이 스크립트를 실행합니다.

마이그레이션 헬퍼와 버전 관리#

데이터베이스 마이그레이션의 여러 일반적인 패턴에 사용할 수 있는 다양한 헬퍼 메서드가 있습니다. 이러한 헬퍼는 Gitlab::Database::MigrationHelpers 및 관련 모듈에서 찾을 수 있습니다.

시간이 지나도 헬퍼의 동작을 바꿀 수 있도록 마이그레이션 헬퍼에는 버전 관리 체계를 적용합니다. 이를 통해 이미 존재하는 마이그레이션에서는 헬퍼의 동작을 유지하고 새 마이그레이션에서만 동작을 바꿀 수 있습니다.

이를 위해 모든 데이터베이스 마이그레이션은 "버전이 있는" 클래스인 Gitlab::Database::Migration을 상속해야 합니다. 새 마이그레이션에서는 최신 버전의 마이그레이션 헬퍼를 사용하도록 최신 버전을 사용해야 합니다 (최신 버전은 Gitlab::Database::Migration::MIGRATION_CLASSES에서 확인할 수 있습니다).

다음 예시에서는 마이그레이션 클래스의 버전 2.1을 사용합니다.

class TestMigration < Gitlab::Database::Migration[2.1]
  def change
  end
end

Gitlab::Database::MigrationHelpers를 마이그레이션에 직접 포함하지 않습니다. 대신 최신 버전의 Gitlab::Database::Migration을 사용합니다. 이 클래스는 최신 버전의 마이그레이션 헬퍼를 자동으로 노출합니다.

데이터베이스 잠금 획득 시 재시도 메커니즘#

데이터베이스 스키마를 변경할 때는 헬퍼 메서드로 DDL(Data Definition Language) 구문을 호출합니다. 경우에 따라 이러한 DDL 구문에는 특정 데이터베이스 잠금이 필요합니다.

예시:

def change
  remove_column :users, :full_name, :string
end

이 마이그레이션을 실행하려면 users 테이블에 대한 배타적 잠금이 필요합니다. 테이블에 다른 프로세스가 동시에 접근하고 수정하는 경우 잠금을 획득하는 데 시간이 걸릴 수 있습니다. 잠금 요청은 큐에서 대기하며, 큐에 들어간 뒤에는 users 테이블에 대한 다른 쿼리도 차단할 수 있습니다.

PostgreSQL 잠금에 대한 자세한 내용은 Explicit Locking을 참고합니다.

안정성을 위해 GitLab.com에는 짧은 statement_timeout이 설정되어 있습니다. 마이그레이션이 호출되면 모든 데이터베이스 쿼리는 정해진 시간 안에 실행되어야 합니다. 최악의 경우 요청이 잠금 큐에서 대기하면서 설정된 구문 타임아웃 시간 동안 다른 쿼리를 차단하다가 canceling statement due to statement timeout 오류로 실패합니다.

이 문제는 애플리케이션 업그레이드 프로세스를 실패하게 만들 수 있고, 테이블에 잠시 접근할 수 없게 되므로 애플리케이션 안정성 문제로 이어질 수도 있습니다.

데이터베이스 마이그레이션의 신뢰성과 안정성을 높이기 위해 GitLab 코드베이스는 lock_timeout 설정과 시도 사이의 대기 시간을 달리하며 작업을 재시도하는 메서드를 제공합니다. 필요한 잠금을 더 짧게 여러 번 획득하려고 시도하면 그 사이에 데이터베이스가 다른 구문을 처리할 수 있습니다.

non_transactional 마이그레이션을 사용할 때 with_lock_retries 메서드를 사용하면 마이그레이션 안에서 실행되는 코드 블록의 잠금 획득 재시도와 타임아웃 설정을 명시적으로 제어할 수 있습니다.

트랜잭션 마이그레이션#

일반 마이그레이션은 전체 마이그레이션을 트랜잭션으로 실행합니다. 잠금 재시도 메커니즘은 기본적으로 활성화되어 있습니다(disable_ddl_transaction!을 사용하는 경우 제외).

이에 따라 마이그레이션의 잠금 타임아웃이 제어됩니다. 또한 타임아웃 안에 잠금을 얻지 못하면 전체 마이그레이션을 재시도할 수도 있습니다.

마이그레이션이 서로 다른 객체에 대해 여러 잠금을 획득해야 하는 경우가 가끔 있습니다. 카탈로그 비대화를 방지하려면 DDL을 수행하기 전에 그러한 잠금을 모두 명시적으로 요청합니다. 더 나은 전략은 한 번에 잠금 하나만 획득하면 되도록 마이그레이션을 나누는 것입니다.

같은 테이블에 대한 여러 변경#

잠금 재시도 방식을 활성화하면 모든 작업이 하나의 트랜잭션으로 감싸집니다. 잠금을 얻은 뒤에는 나중에 다른 잠금을 얻으려고 시도하기보다 트랜잭션 안에서 가능한 한 많은 작업을 수행해야 합니다. 블록 안에서 오래 걸리는 데이터베이스 구문을 실행할 때는 주의합니다. 획득한 잠금은 트랜잭션(블록)이 끝날 때까지 유지되며, 잠금 유형에 따라 다른 데이터베이스 작업을 차단할 수 있습니다.

def up
  add_column :users, :full_name, :string
  add_column :users, :bio, :string
end

def down
  remove_column :users, :full_name
  remove_column :users, :bio
end

칼럼의 기본값 변경#

여러 릴리스에 걸친 프로세스를 따르지 않으면 칼럼 기본값을 변경할 때 애플리케이션 다운타임이 발생할 수 있습니다. 자세한 내용은 칼럼 기본값 변경 시 다운타임 방지를 참고합니다.

def up
  change_column_default :merge_requests, :lock_version, from: nil, to: 0
end

def down
  change_column_default :merge_requests, :lock_version, from: 0, to: nil
end

외래 키가 두 개인 새 테이블 생성#

트랜잭션당 외래 키는 하나만 생성해야 합니다. 외래 키 제약 조건을 추가하려면 참조되는 테이블에 대한 SHARE ROW EXCLUSIVE 잠금이 필요하며, 같은 트랜잭션에서 여러 테이블을 잠그는 것은 피해야 하기 때문입니다.

이를 위해 마이그레이션이 세 개 필요합니다.

  1. 외래 키 없이 테이블을 생성합니다(인덱스 포함).
  2. 첫 번째 테이블에 외래 키를 추가합니다.
  3. 두 번째 테이블에 외래 키를 추가합니다.

테이블 생성:

def up
  create_table :imports do |t|
    t.bigint :project_id, null: false
    t.bigint :user_id, null: false
    t.string :jid, limit: 255

    t.index :project_id
    t.index :user_id
  end
end

def down
  drop_table :imports
end

projects에 외래 키 추가:

이 경우 add_concurrent_foreign_key 메서드를 사용할 수 있습니다. 이 헬퍼 메서드에는 잠금 재시도가 내장되어 있기 때문입니다.

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :project_id
  end
end

users에 외래 키 추가:

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :users, column: :user_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :user_id
  end
end

2단계 외래 키 검증으로 잠금 경합 최소화#

트래픽이 많은 테이블이거나 배포 중 잠금 경합을 일으킬 수 있는 외래 키를 추가할 때는 외래 키 생성과 검증을 서로 다른 마이그레이션으로 분리하는 것을 고려합니다. 이는 파티션 테이블에서 특히 중요합니다. 파티션 테이블에서는 부모 테이블에 외래 키를 추가하기 전에 각 파티션에 외래 키를 개별적으로 추가해야 합니다.

  1. 첫 번째 마이그레이션: 배포 중 쓰기가 차단되지 않도록 validate: false로 외래 키를 추가합니다
  2. 두 번째 마이그레이션: prepare_async_foreign_key_validation을 사용해 외래 키를 비동기로 검증합니다
  3. 세 번째 마이그레이션: 검증이 완료된 후 (파티션 테이블의 경우) 부모 외래 키를 추가합니다

add_concurrent_foreign_key는 이미 내부적으로 2단계 검증(검증 없이 추가한 다음 검증)을 수행하지만, 이 단계를 서로 다른 마이그레이션으로 분리하면 검증을 배포 기간 밖에서 수행할 수 있어 배포 시간과 위험이 줄어들므로 배포에 유연성이 생깁니다.

이 접근 방식은 잠금 경합으로 인한 배포 차단을 최소화합니다. 이 사례와 같은 프로덕션 인시던트에서는 중요한 배포 기간 중 외래 키 검증이 예상보다 오래 걸렸습니다.

비트랜잭션 마이그레이션에서의 사용#

disable_ddl_transaction!로 트랜잭션 마이그레이션을 비활성화한 경우에만 with_lock_retries 헬퍼로 개별 단계 시퀀스를 보호할 수 있습니다. 이 헬퍼는 주어진 블록을 실행하기 위해 트랜잭션을 엽니다.

사용자 지정 RuboCop 규칙이 잠금 재시도 블록 안에 허용된 메서드만 넣을 수 있도록 보장합니다.

disable_ddl_transaction!

def up
  with_lock_retries do
    add_column(:users, :name, :text, if_not_exists: true)
  end

  add_text_limit :users, :name, 255 # Includes constraint validation (full table scan)
end

RuboCop 규칙은 일반적으로 아래에 나열된 표준 Rails 마이그레이션 메서드를 허용합니다. 다음 예시는 RuboCop 위반을 일으킵니다.

disable_ddl_transaction!

def up
  with_lock_retries do
    add_concurrent_index :users, :name
  end
end

헬퍼 메서드를 사용할 시점#

with_lock_retries 헬퍼 메서드는 실행이 이미 열린 트랜잭션 안에 있지 않을 때만 사용할 수 있습니다(PostgreSQL 서브트랜잭션은 사용하지 않는 것이 좋습니다). 표준 Rails 마이그레이션 헬퍼 메서드와 함께 사용할 수 있습니다. 마이그레이션 헬퍼를 둘 이상 호출하는 것은 같은 테이블에서 실행되는 한 문제가 되지 않습니다.

데이터베이스 마이그레이션에 트래픽이 많은 테이블 중 하나가 관련된 경우 with_lock_retries 헬퍼 메서드를 사용하는 것이 좋습니다.

변경 예시:

  • add_foreign_key / remove_foreign_key
  • add_column / remove_column
  • change_column_default
  • create_table / drop_table

with_lock_retries 메서드는 change 메서드 안에서 사용할 수 없습니다. 마이그레이션을 되돌릴 수 있게 하려면 up과 down 메서드를 직접 정의해야 합니다.

헬퍼 메서드의 동작 방식#

  1. 50회 반복합니다.
  2. 각 반복마다 미리 구성된 lock_timeout을 설정합니다.
  3. 주어진 블록(remove_column)을 실행합니다.
  4. LockWaitTimeout 오류가 발생하면 미리 구성된 sleep_time 동안 대기한 뒤 블록을 재시도합니다.
  5. 오류가 발생하지 않으면 현재 반복에서 블록이 성공적으로 실행된 것입니다.

자세한 내용은 Gitlab::Database::WithLockRetries 클래스를 확인합니다. with_lock_retries 헬퍼 메서드는 Gitlab::Database::MigrationHelpers 모듈에 구현되어 있습니다.

최악의 경우 이 메서드는 다음과 같이 동작합니다.

  • 블록을 40분에 걸쳐 최대 50회 실행합니다.
    • 대부분의 시간은 각 반복 후 미리 구성된 대기 시간에 소요됩니다.
  • 50번째 재시도 이후에는 표준 마이그레이션 호출과 마찬가지로 lock_timeout 없이 블록을 실행합니다.
  • 잠금을 획득할 수 없으면 마이그레이션이 statement timeout 오류로 실패합니다.

users 테이블에 접근하는 매우 오래 실행되는 트랜잭션(40분 이상)이 있으면 마이그레이션이 실패할 수 있습니다.

SQL 수준의 잠금 재시도 방식#

이 섹션에서는 lock_timeout의 사용을 보여 주는 간단한 SQL 예시를 제공합니다. 주어진 스니펫을 여러 psql 세션에서 실행하며 따라 해 볼 수 있습니다.

테이블에 칼럼을 추가하기 위해 테이블을 변경할 때는 대부분의 잠금 유형과 충돌하는 AccessExclusiveLock이 테이블에 필요합니다. 대상 테이블이 매우 바쁜 테이블이면 칼럼을 추가하는 트랜잭션이 AccessExclusiveLock을 적시에 획득하지 못할 수 있습니다.

트랜잭션이 테이블에 행을 삽입하려고 한다고 가정합니다.

-- Transaction 1
BEGIN;
INSERT INTO my_notes (id) VALUES (1);

이 시점에 Transaction 1은 my_notes에 대한 RowExclusiveLock을 획득했습니다. Transaction 1은 커밋하거나 중단하기 전에 더 많은 구문을 실행할 수 있습니다. my_notes를 다루는 비슷한 동시 트랜잭션이 더 있을 수도 있습니다.

트랜잭션 마이그레이션이 잠금 재시도 헬퍼를 사용하지 않고 테이블에 칼럼을 추가하려고 한다고 가정합니다.

-- Transaction 2
BEGIN;
ALTER TABLE my_notes ADD COLUMN title text;

Transaction 2는 my_notes 테이블에 대한 AccessExclusiveLock을 획득할 수 없어 차단됩니다. Transaction 1이 아직 실행 중이고 my_notes에 대한 RowExclusiveLock을 보유하고 있기 때문입니다.

더 해로운 영향은 Transaction 2가 AccessExclusiveLock을 획득하기 위해 큐에서 대기하기 때문에 평소에는 Transaction 1과 충돌하지 않을 트랜잭션까지 차단된다는 점입니다. 정상적인 상황에서는 다른 트랜잭션이 Transaction 1과 동시에 같은 테이블 my_notes에서 읽고 쓰려고 해도 해당 트랜잭션은 통과합니다. 읽기와 쓰기에 필요한 잠금이 Transaction 1이 보유한 RowExclusiveLock과 충돌하지 않기 때문입니다. 그러나 AccessExclusiveLock 획득 요청이 큐에 들어가면, Transaction 1과 나란히 동시에 실행될 수 있는 요청이더라도 테이블에 대한 충돌하는 잠금을 요구하는 이후의 요청은 차단됩니다.

with_lock_retries를 사용하면 Transaction 2는 지정된 시간 안에 잠금을 획득하지 못한 뒤 빠르게 타임아웃되고 다른 트랜잭션이 진행되도록 허용합니다.

-- Transaction 2 (version with lock timeout)
BEGIN;
SET LOCAL lock_timeout to '100ms'; -- added by the lock retry helper.
ALTER TABLE my_notes ADD COLUMN title text;

잠금 재시도 헬퍼는 성공할 때까지 같은 트랜잭션을 서로 다른 시간 간격으로 반복해서 시도합니다.

SET LOCAL은 매개변수(lock_timeout) 변경의 범위를 트랜잭션으로 한정합니다.

인덱스 제거#

테이블이 비어 있지 않은 상태에서 인덱스를 제거할 때는 일반 remove_index 메서드 대신 remove_concurrent_index 메서드를 사용해야 합니다. remove_concurrent_index 메서드는 인덱스를 동시에 삭제하므로 잠금이 필요하지 않고 다운타임도 필요하지 않습니다. 이 메서드를 사용하려면 다음과 같이 마이그레이션 클래스 본문에서 disable_ddl_transaction! 메서드를 호출해 단일 트랜잭션 모드를 비활성화해야 합니다.

class MyMigration < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  INDEX_NAME = 'index_name'

  def up
    remove_concurrent_index :table_name, :column_name, name: INDEX_NAME
  end
end

Grafana로 인덱스가 사용되지 않는지 확인할 수 있습니다.

sum by (type)(rate(pg_stat_user_indexes_idx_scan{env="gprd", indexrelname="INSERT INDEX NAME HERE"}[30d]))

인덱스를 제거하기 전에 인덱스가 존재하는지 확인할 필요는 없지만, 제거할 인덱스의 이름은 반드시 지정해야 합니다. 이름은 remove_index 또는 remove_concurrent_index의 해당 형식에 옵션으로 이름을 전달하거나, remove_concurrent_index_by_name 메서드를 사용해 지정할 수 있습니다. 올바른 인덱스가 제거되도록 이름을 명시적으로 지정하는 것이 중요합니다.

작은 테이블(빈 테이블이거나 레코드가 1,000개 미만인 테이블)의 경우 disable_ddl_transaction!이 필요하지 않은 다른 작업과 결합해 단일 트랜잭션 마이그레이션에서 remove_index를 사용하는 것이 좋습니다.

인덱스 비활성화#

인덱스 비활성화는 안전한 작업이 아닙니다.

인덱스 추가#

인덱스를 추가하기 전에 인덱스가 필요한지 검토합니다. 데이터베이스 인덱스 추가 가이드에는 인덱스가 필요한지 판단하는 데 도움이 되는 더 자세한 내용과 인덱스 추가 모범 사례가 있습니다.

고유 인덱스#

Cells 아키텍처의 고유 인덱스 요구 사항에 대한 자세한 내용은 Cells의 고유 제약 조건을 참고합니다.

인덱스 존재 여부 테스트#

인덱스의 유무에 따른 조건부 로직이 마이그레이션에 필요하다면 인덱스 이름으로 해당 인덱스의 존재 여부를 테스트해야 합니다. 이렇게 하면 Rails가 인덱스 정의를 비교하는 방식 때문에 생기는 문제를 피할 수 있으며, 그러한 문제는 예기치 않은 결과로 이어질 수 있습니다.

자세한 내용은 데이터베이스 인덱스 추가 가이드를 검토합니다.

NOT NULL 제약 조건#

자세한 내용은 NOT NULL 제약 조건 스타일 가이드를 참고합니다.

기본값이 있는 칼럼 추가#

GitLab의 최소 버전이 PostgreSQL 11이므로 기본값이 있는 칼럼을 추가하는 일이 훨씬 쉬워졌으며, 모든 경우에 표준 add_column 헬퍼를 사용해야 합니다.

PostgreSQL 11 이전에는 기본값이 있는 칼럼을 추가하면 테이블 전체를 다시 써야 했으므로 문제가 되었습니다.

null을 허용하지 않는 칼럼의 기본값 제거#

null을 허용하지 않는 칼럼을 추가하고 기본값을 사용해 기존 데이터를 채웠다면 적어도 애플리케이션 코드가 업데이트될 때까지는 그 기본값을 유지해야 합니다. 같은 마이그레이션에서 기본값을 제거할 수는 없습니다. 마이그레이션은 모델 코드가 업데이트되기 전에 실행되며 모델은 이전 스키마 캐시를 가지고 있어 이 칼럼을 알지 못하고 값을 설정할 수도 없기 때문입니다. 이 경우 다음을 권장합니다.

  1. 표준 마이그레이션에서 기본값이 있는 칼럼을 추가합니다.
  2. 배포 후 마이그레이션에서 기본값을 제거합니다.

배포 후 마이그레이션은 애플리케이션이 재시작된 후에 실행되므로 새 칼럼이 인식된 상태임을 보장합니다.

칼럼 기본값 변경#

change_column_default로 칼럼의 기본값을 변경하는 것이 큰 테이블에서는 비용이 많이 들고 서비스에 지장을 주는 작업이라고 생각할 수 있지만 실제로는 그렇지 않습니다.

다음 마이그레이션을 예로 들어 보겠습니다.

class DefaultRequestAccessGroups < Gitlab::Database::Migration[2.1]
  def change
    change_column_default(:namespaces, :request_access_enabled, from: false, to: true)
  end
end

위 마이그레이션은 가장 큰 테이블 중 하나인 namespaces의 칼럼 기본값을 변경합니다. 이는 다음과 같이 옮길 수 있습니다.

ALTER TABLE namespaces
ALTER COLUMN request_access_enabled
SET DEFAULT false

이 경우 기본값이 이미 존재하고 request_access_enabled 칼럼의 메타데이터만 변경하므로, namespaces 테이블의 기존 레코드를 모두 다시 쓰지는 않습니다. 기본값이 있는 새 칼럼을 만들 때만 모든 레코드를 다시 쓰게 됩니다.

Note

더 빠른 null이 아닌 기본값을 사용하는 ALTER TABLE ADD COLUMN이 PostgreSQL 11.0에서 도입되어, 기본값이 있는 새 칼럼을 추가할 때 테이블을 다시 쓸 필요가 없어졌습니다.

위에서 언급한 이유로 disable_ddl_transaction! 없이 단일 트랜잭션 마이그레이션에서 change_column_default를 사용해도 안전합니다.

기존 칼럼 업데이트#

기존 칼럼을 특정 값으로 업데이트하려면 update_column_in_batches를 사용할 수 있습니다. 이 메서드는 업데이트를 배치로 나누어 하나의 구문에서 너무 많은 행을 업데이트하지 않도록 합니다.

다음은 projects 테이블에서 some_column이 'hello'인 행의 foo 칼럼을 10으로 업데이트합니다.

update_column_in_batches(:projects, :foo, 10) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

계산된 값으로 업데이트해야 한다면 값을 Arel.sql로 감싸 Arel이 SQL 리터럴로 취급하게 할 수 있습니다. 이는 Rails 6에서 요구하는 지원 중단 대응이기도 합니다.

아래 예시는 위 예시와 같지만 값을 bar 칼럼과 baz 칼럼의 곱으로 설정합니다.

update_value = Arel.sql('bar * baz')

update_column_in_batches(:projects, :foo, update_value) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

update_column_in_batches의 경우 테이블의 행 중 일부만 업데이트하는 것이라면 큰 테이블에서 실행해도 괜찮을 수 있습니다. 하지만 사전에 GitLab.com 스테이징 환경에서 검증하지 않고 (또는 다른 사람에게 검증을 요청하지 않고) 이를 넘겨짚지 않습니다.

외래 키 제약 조건 제거#

외래 키 제약 조건을 제거할 때는 외래 키와 관련된 두 테이블 모두에 대한 잠금을 획득해야 합니다. 쓰기가 많은 테이블에서는 with_lock_retries를 사용하는 것이 좋습니다. 그렇지 않으면 제때 잠금을 획득하지 못할 수 있습니다. 잠금을 획득할 때 교착 상태가 발생할 수도 있습니다. 애플리케이션은 보통 parent,child 순서로 쓰지만, 외래 키를 제거하면 child,parent 순서로 잠금을 획득하기 때문입니다. 이를 해결하려면 parent,child 순서로 잠금을 명시적으로 획득하면 됩니다. 예를 들면 다음과 같습니다.

disable_ddl_transaction!

def up
  with_lock_retries do
    execute('lock table ci_pipelines, ci_builds in access exclusive mode')

    remove_foreign_key :ci_builds, to_table: :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
  end
end

def down
  add_concurrent_foreign_key :ci_builds, :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
end

데이터베이스 테이블 삭제#

Note

테이블을 삭제한 후에는 데이터베이스 딕셔너리 가이드의 단계에 따라 데이터베이스 딕셔너리에 추가해야 합니다.

데이터베이스 테이블을 삭제하는 일은 드물며, Rails가 제공하는 drop_table 메서드는 일반적으로 안전한 것으로 간주됩니다. 테이블을 삭제하기 전에 다음을 고려합니다.

테이블에 트래픽이 많은 테이블(예: projects)을 가리키는 외래 키가 있으면 DROP TABLE 구문은 statement timeout 오류로 실패할 때까지 동시 트래픽을 지연시킬 가능성이 높습니다.

테이블에 레코드가 없고(기능이 사용된 적이 없음) 외래 키도 없는 경우:

  • 마이그레이션에서 drop_table 메서드를 사용합니다.
def change
  drop_table :my_table
end

테이블에 레코드가 있지만 외래 키는 없는 경우:

  • 모델, 컨트롤러, 서비스와 같이 테이블과 관련된 애플리케이션 코드를 제거합니다.
  • 배포 후 마이그레이션에서 drop_table을 사용합니다.

코드가 사용되지 않는다고 확신한다면 이 모두를 하나의 마이그레이션에 넣을 수 있습니다. 위험을 조금 더 줄이려면 애플리케이션 변경 사항이 머지된 후 마이그레이션을 두 번째 머지 리퀘스트에 넣는 것을 고려합니다. 이 방식은 롤백할 기회를 제공합니다.

def up
  drop_table :my_table
end

def down
  # create_table ...
end

테이블에 외래 키가 있는 경우:

  • 모델, 컨트롤러, 서비스와 같이 테이블과 관련된 애플리케이션 코드를 제거합니다.
  • 배포 후 마이그레이션에서 with_lock_retries 헬퍼 메서드를 사용해 외래 키를 제거합니다. 여러 외래 키를 제거한다면 잠금 경합을 피하기 위해 각 키를 별도의 마이그레이션에서 삭제해야 합니다.
  • 그 이후의 다른 배포 후 마이그레이션에서 drop_table을 사용합니다.

코드가 사용되지 않는다고 확신한다면 이 모두를 하나의 마이그레이션에 넣을 수 있습니다. 위험을 조금 더 줄이려면 애플리케이션 변경 사항이 머지된 후 마이그레이션을 두 번째 머지 리퀘스트에 넣는 것을 고려합니다. 이 방식은 롤백할 기회를 제공합니다.

비트랜잭션 마이그레이션으로 projects 테이블의 외래 키 제거:

# first migration file
class RemovingForeignKeyMigrationClass < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    with_lock_retries do
      remove_foreign_key :my_table, :projects
    end
  end

  def down
    add_concurrent_foreign_key :my_table, :projects, column: COLUMN_NAME
  end
end

테이블 삭제:

# second migration file
class DroppingTableMigrationClass < Gitlab::Database::Migration[2.1]
  def up
    drop_table :my_table
  end

  def down
    # create_table with the same schema but without the removed foreign key ...
  end
end

시퀀스 삭제#

시퀀스를 삭제하는 일은 드물지만, 데이터베이스 팀이 제공하는 drop_sequence 메서드를 사용할 수 있습니다.

내부적으로는 다음과 같이 동작합니다.

시퀀스 제거:

  • 시퀀스가 실제로 사용되고 있다면 기본값을 제거합니다.
  • DROP SEQUENCE를 실행합니다.

시퀀스 다시 추가:

  • 현재 값을 지정할 수 있는 방식으로 시퀀스를 생성합니다.
  • 칼럼의 기본값을 변경합니다.

Rails 마이그레이션 예시:

class DropSequenceTest < Gitlab::Database::Migration[2.1]
  def up
    drop_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq)
  end

  def down
    default_value = Ci::Pipeline.maximum(:id) + 10_000

    add_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq, default_value)
  end
end
Note

외래 키가 있는 칼럼에는 add_sequence를 사용하지 않아야 합니다. 이러한 칼럼에 시퀀스를 추가하는 것은 down 메서드(이전 스키마 상태 복원)에서만 허용됩니다.

테이블 비우기#

테이블을 비우는 일은 드물지만, 데이터베이스 팀이 제공하는 truncate_tables! 메서드를 사용할 수 있습니다.

내부적으로는 다음과 같이 동작합니다.

  • 비울 테이블의 gitlab_schema를 찾습니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있으면 TRUNCATE 구문을 실행합니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있지 않으면 아무 작업도 하지 않습니다.

기본 키 교체#

파티션 키가 기본 키에 포함되어야 하므로 테이블을 파티셔닝하려면 기본 키를 교체해야 합니다.

데이터베이스 팀이 제공하는 swap_primary_key 메서드를 사용할 수 있습니다.

내부적으로는 다음과 같이 동작합니다.

  • 기본 키 제약 조건을 삭제합니다.
  • 미리 정의해 둔 인덱스를 사용해 기본 키를 추가합니다.
class SwapPrimaryKey < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  TABLE_NAME = :table_name
  PRIMARY_KEY = :table_name_pkey
  OLD_INDEX_NAME = :old_index_name
  NEW_INDEX_NAME = :new_index_name

  def up
    swap_primary_key(TABLE_NAME, PRIMARY_KEY, NEW_INDEX_NAME)
  end

  def down
    add_concurrent_index(TABLE_NAME, :id, unique: true, name: OLD_INDEX_NAME)
    add_concurrent_index(TABLE_NAME, [:id, :partition_id], unique: true, name: NEW_INDEX_NAME)

    unswap_primary_key(TABLE_NAME, PRIMARY_KEY, OLD_INDEX_NAME)
  end
end
Note

기본 키를 교체하려면 별도의 마이그레이션에서 새 인덱스를 미리 만들어 두어야 합니다.

정수 칼럼 유형#

기본적으로 정수 칼럼은 최대 4바이트(32비트) 숫자를 담을 수 있습니다. 이는 최댓값 2,147,483,647에 해당합니다. 파일 크기를 바이트 단위로 담는 칼럼을 만들 때 이 점에 유의합니다. 파일 크기를 바이트로 추적한다면 최대 파일 크기가 2GB를 조금 넘는 수준으로 제한됩니다.

정수 칼럼이 최대 8바이트(64비트) 숫자를 담을 수 있게 하려면 제한을 8바이트로 명시적으로 설정합니다. 이렇게 하면 칼럼이 최대 9,223,372,036,854,775,807까지의 값을 담을 수 있습니다.

Rails 마이그레이션 예시:

add_column(:projects, :foo, :integer, default: 10, limit: 8)

문자열과 Text 데이터 유형#

자세한 내용은 text 데이터 유형 스타일 가이드를 참고합니다.

타임스탬프 칼럼 유형#

기본적으로 Rails는 시간대 정보 없이 타임스탬프 데이터를 저장하는 timestamp 데이터 유형을 사용합니다. timestamp 데이터 유형은 add_timestamps 또는 timestamps 메서드를 호출해 사용합니다.

또한 Rails는 :datetime 데이터 유형을 timestamp로 변환합니다.

예시:

# timestamps
create_table :users do |t|
  t.timestamps
end

# add_timestamps
def up
  add_timestamps :users
end

# :datetime
def up
  add_column :users, :last_sign_in, :datetime
end

이러한 메서드 대신 다음 메서드를 사용해 시간대가 포함된 타임스탬프를 저장해야 합니다.

  • add_timestamps_with_timezone
  • timestamps_with_timezone
  • datetime_with_timezone

이렇게 하면 모든 타임스탬프에 시간대가 지정됩니다. 그 결과 시스템의 시간대가 바뀌어도 기존 타임스탬프가 갑자기 다른 시간대를 사용하지 않게 됩니다. 또한 처음에 어떤 시간대가 사용되었는지도 분명하게 알 수 있습니다.

데이터베이스에 JSON 저장#

Rails 5는 JSONB(바이너리 JSON) 칼럼 유형을 기본으로 지원합니다. 이 칼럼을 추가하는 마이그레이션 예시:

class AddOptionsToBuildMetadata < Gitlab::Database::Migration[2.1]
  def change
    add_column :ci_builds_metadata, :config_options, :jsonb
  end
end

기본적으로 해시 키는 문자열입니다. 선택적으로 사용자 지정 데이터 유형을 추가해 키에 다른 방식으로 접근하게 할 수 있습니다.

class BuildMetadata
  attribute :config_options, ::Gitlab::Database::Type::IndifferentJsonb.new # for indifferent access or ::Gitlab::Database::Type::SymbolizedJsonb.new if you need symbols only as keys.
end

JSONB 칼럼을 사용할 때는 JsonSchemaValidator를 사용해 시간이 지나도 삽입되는 데이터를 통제해야 합니다. 큰 JSONB 데이터로 인한 성능 문제를 방지하기 위해 size_limit도 지정해야 하며, 권장 최댓값은 64KB입니다.

JSON 스키마에서 additionalProperties: false를 사용한다면 속성을 추가하거나 제거할 때의 배포 요구 사항은 스키마 검증이 있는 JSON/JSONB 칼럼 변경 을 참고합니다.

제한 없는 JSONB 증가는 수백만 개의 데이터베이스 레코드에서 메모리 압박과 쿼리 성능 저하를 일으킬 수 있으므로, JsonbSizeLimit cop이 새 검증에 이 요구 사항을 강제합니다. 더 큰 데이터 세트에는 오브젝트 스토리지를 사용하고 데이터베이스에는 참조만 저장합니다.

class BuildMetadata
  validates :config_options, json_schema: { filename: 'build_metadata_config_option', size_limit: 64.kilobytes }
end

또한 JSONB 칼럼의 키를 ActiveRecord 속성으로 노출할 수 있습니다. 복잡한 검증이나 ActiveRecord 변경 추적이 필요할 때 사용합니다. 이 기능은 jsonb_accessor gem이 제공하며 JsonSchemaValidator를 대체하지 않습니다.

module Organizations
  class OrganizationSetting < ApplicationRecord
    belongs_to :organization

    validates :settings, json_schema: { filename: "organization_settings" }

    jsonb_accessor :settings,
      restricted_visibility_levels: [:integer, { array: true }]

    validates_each :restricted_visibility_levels do |record, attr, value|
      value&.each do |level|
        unless Gitlab::VisibilityLevel.options.value?(level)
          record.errors.add(attr, format(_("'%{level}' is not a valid visibility level"), level: level))
        end
      end
    end
  end
end

이제 restricted_visibility_levels를 ActiveRecord 속성으로 사용할 수 있습니다.

> s = Organizations::OrganizationSetting.find(1)
=> #
> s.settings
=> {"restricted_visibility_levels"=>[20]}
> s.restricted_visibility_levels
=> [20]
> s.restricted_visibility_levels = [0]
=> [0]
> s.changes
=> {"settings"=>[{"restricted_visibility_levels"=>[20]}, {"restricted_visibility_levels"=>[0]}], "restricted_visibility_levels"=>[[20], [0]]}

암호화된 속성#

encrypts 속성을 데이터베이스에 :text로 저장하지 않고 :jsonb를 대신 사용합니다. 이렇게 하면 PostgreSQL의 JSONB 유형을 사용하며 저장 공간을 더 효율적으로 쓸 수 있습니다.

class AddSecretToSomething < Gitlab::Database::Migration[2.1]
  def change
    add_column :something, :secret, :jsonb, null: true
  end
end

암호화된 속성을 JSONB 칼럼에 저장할 때는 Active Record Encryption 권장 사항을 따르는 길이 검증을 추가하는 것이 좋습니다. 대부분의 암호화된 속성에는 최대 길이 510이면 충분합니다.

class Something < ApplicationRecord
  encrypts :secret
  validates :secret, length: { maximum: 510 }
end

타입 안전성을 갖춘 강화된 검증#

데이터 무결성을 더 높이려면 암호화 전에 평문 값의 형식과 유형을 모두 검증합니다.

class Something < ApplicationRecord
  encrypts :secret

  validates :secret,
            length: { maximum: 510 },
            format: { with: /\A[a-zA-Z]+\z/, allow_nil: true }

  validate :ensure_string_type

  private

  def ensure_string_type
    unless secret.is_a?(String) || secret.nil?
      errors.add(:secret, "must be a string")
    end
  end
end

이 접근 방식은 Rails의 형식 검증기를 사용해 (기반이 되는 JSONB 값이 아니라) 평문 값을 검증하고, 속성이 String 또는 nil임을 단언해 타입 안전성을 보장합니다.

테스트#

Rails 마이그레이션 테스트 스타일 가이드를 참고합니다.

데이터 마이그레이션#

일반적인 ActiveRecord 구문보다 Arel과 일반 SQL을 우선 사용합니다. 일반 SQL을 사용하는 경우 모든 입력을 quote_string 헬퍼로 직접 인용해야 합니다.

Arel 예시:

users = Arel::Table.new(:users)
users.group(users[:user_id]).having(users[:id].count.gt(5))

#update other tables with these results

일반 SQL과 quote_string 헬퍼 예시:

select_all("SELECT name, COUNT(id) as cnt FROM tags GROUP BY name HAVING COUNT(id) > 1").each do |tag|
  tag_name = quote_string(tag["name"])
  duplicate_ids = select_all("SELECT id FROM tags WHERE name = '#{tag_name}'").map{|tag| tag["id"]}
  origin_tag_id = duplicate_ids.first
  duplicate_ids.delete origin_tag_id

  execute("UPDATE taggings SET tag_id = #{origin_tag_id} WHERE tag_id IN(#{duplicate_ids.join(",")})")
  execute("DELETE FROM tags WHERE id IN(#{duplicate_ids.join(",")})")
end

더 복잡한 로직이 필요하다면 마이그레이션에서만 쓰는 모델을 정의해 사용할 수 있습니다. 예를 들면 다음과 같습니다.

class MyMigration < Gitlab::Database::Migration[2.1]
  class Project < MigrationRecord
    self.table_name = 'projects'
  end

  def up
    # Reset the column information of all the models that update the database
    # to ensure the Active Record's knowledge of the table structure is current
    Project.reset_column_information

    # ... ...
  end
end

이렇게 할 때는 모델의 테이블 이름이 클래스 이름이나 네임스페이스에서 유추되지 않도록 명시적으로 설정해야 합니다.

마이그레이션에서 모델을 사용할 때의 한계에 유의합니다.

기존 데이터 수정#

대부분의 경우 데이터베이스의 데이터를 수정할 때는 배치로 마이그레이션하는 것이 좋습니다.

컬렉션을 성능 좋게 순회하는 과정을 돕는 헬퍼 each_batch_range를 사용합니다. 기본 배치 크기는 BATCH_SIZE 상수에 정의되어 있습니다.

다음 예시를 통해 사용법을 파악할 수 있습니다.

배치로 데이터 삭제:

disable_ddl_transaction!

def up
  each_batch_range('ci_pending_builds', scope: ->(table) { table.ref_protected }, of: BATCH_SIZE) do |min, max|
    execute <<~SQL
      DELETE FROM ci_pending_builds
        USING ci_builds
        WHERE ci_builds.id = ci_pending_builds.build_id
          AND ci_builds.status != 'pending'
          AND ci_builds.type = 'Ci::Build'
          AND ci_pending_builds.id BETWEEN #{min} AND #{max}
    SQL
  end
end
  • 첫 번째 인수는 수정할 테이블입니다. 'ci_pending_builds'
  • 두 번째 인수는 선택한 관련 데이터 세트를 가져오는 람다를 호출합니다(기본값은 .all). scope: ->(table) { table.ref_protected }
  • 세 번째 인수는 배치 크기입니다(기본값은 BATCH_SIZE 상수에 설정되어 있음). of: BATCH_SIZE

이 헬퍼의 사용법을 보여 주는 예시 머지 리퀘스트이 있습니다.

마이그레이션에서 애플리케이션 코드 사용 (권장하지 않음)#

마이그레이션에서 애플리케이션 코드(모델 포함)를 사용하는 것은 일반적으로 권장하지 않습니다. 마이그레이션은 오랫동안 남아 있고 마이그레이션이 의존하는 애플리케이션 코드는 이후에 바뀌어 마이그레이션이 깨질 수 있기 때문입니다. 과거에는 일부 백그라운드 마이그레이션이 여러 파일에 흩어진 수백 줄의 코드를 마이그레이션에 복사하는 것을 피하기 위해 애플리케이션 코드를 사용해야 했습니다. 이처럼 드문 경우에는 마이그레이션에 충분한 테스트를 갖춰, 나중에 코드를 리팩터링하는 사람이 마이그레이션을 깨뜨렸을 때 이를 알 수 있게 하는 것이 중요합니다. 애플리케이션 코드 사용은 배치 백그라운드 마이그레이션에서도 권장하지 않으며, 모델은 마이그레이션 안에서 선언해야 합니다.

보통 MigrationRecord를 상속하는 클래스를 정의하면 마이그레이션에서 애플리케이션 코드(특히 모델)를 사용하지 않을 수 있습니다(아래 예시 참고).

모델(마이그레이션에서 정의한 모델 포함)을 사용한다면 먼저 reset_column_information을 사용해 칼럼 캐시를 지워야 합니다.

단일 테이블 상속(STI)을 활용하는 모델을 사용한다면 특별히 고려할 사항이 있습니다.

이렇게 하면 사용 중인 칼럼이 이전 마이그레이션에서 변경되어 캐시된 경우에 생기는 문제를 피할 수 있습니다.

예시: users 테이블에 my_column 칼럼 추가#

이전 스키마가 캐시에서 삭제되고 ActiveRecord가 업데이트된 스키마 정보를 불러오도록 User.reset_column_information 명령을 빠뜨리지 않는 것이 중요합니다.

class AddAndSeedMyColumn < Gitlab::Database::Migration[2.1]
  class User < MigrationRecord
    self.table_name = 'users'
  end

  def up
    User.count # Any ActiveRecord calls on the model that caches the column information.

    add_column :users, :my_column, :integer, default: 1

    User.reset_column_information # The old schema is dropped from the cache.
    User.find_each do |user|
      user.my_column = 42 if some_condition # ActiveRecord sees the correct schema here.
      user.save!
    end
  end
end

기본 테이블을 수정한 다음 ActiveRecord를 사용해 접근합니다.

같은 db:migrate 프로세스에서 두 마이그레이션이 실행된다면, 이전의 다른 마이그레이션에서 테이블을 수정한 경우에도 이 방법을 사용해야 합니다.

그 결과는 다음과 같습니다. my_column이 포함된 점에 유의합니다.

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-18T00:41:26.851769 #459802] DEBUG -- :   AddAndSeedMyColumn::User Update (1.1ms)  UPDATE "users" SET "my_column" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["my_column", 42], ["updated_at", "2020-07-17 23:41:26.849044"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

스키마 캐시를 지우지 않으면(User.reset_column_information) 해당 칼럼을 ActiveRecord가 사용하지 않아 의도한 변경이 이루어지지 않으며, 그 결과는 아래와 같습니다. 쿼리에서 my_column이 빠져 있습니다.

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-06T00:37:12.653459 #130101] DEBUG -- :   AddAndSeedMyColumn::User Update (0.5ms)  UPDATE "users" SET "updated_at" = $1 WHERE "users"."id" = $2  [["updated_at", "2020-07-05 23:37:12.652297"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

트래픽이 많은 테이블#

현재 트래픽이 많은 테이블 목록은 다음과 같습니다.

어떤 테이블이 트래픽이 많은 테이블인지 판단하기는 어려울 수 있습니다. GitLab Self-Managed 인스턴스는 사용 패턴이 다른 GitLab의 여러 기능을 사용할 수 있으므로 GitLab.com을 기준으로 한 가정만으로는 충분하지 않습니다.

GitLab.com에서 트래픽이 많은 테이블을 식별하기 위해 다음 지표를 고려합니다. 여기에 링크된 메트릭은 GitLab 내부용입니다.

읽기 작업이 현재 트래픽이 많은 테이블에 견줄 만큼 많은 테이블은 후보가 될 수 있습니다.

일반적으로 GitLab.com의 분석이나 보고만을 위한 칼럼을 트래픽이 많은 테이블에 추가하는 것은 권장하지 않습니다. 이러한 칼럼은 모든 GitLab Self-Managed 인스턴스에 직접적인 기능 가치를 제공하지 못하면서 성능에 부정적인 영향을 줄 수 있습니다.

트리거 생성#

트래픽이 많은 테이블에 트리거를 생성하면 배포 중 잠금 경합 타임아웃이 발생할 수 있습니다. 이를 완화하려면 with_lock_retries 헬퍼 메서드를 사용해 배포 후 마이그레이션에서 트리거를 생성할 수 있습니다. 또한 마이그레이션이 중간에 실패했을 때 재시도할 수 있고 함수나 트리거가 이미 존재해도 실패하지 않도록 마이그레이션을 멱등하게 만들어야 합니다.

class AddTriggersToHighTrafficTable < Gitlab::Database::Migration[2.3]
  milestone '18.10'

  disable_ddl_transaction!

  TRIGGER_FUNCTION_NAME = 'function_name_here'
  TRIGGER_NAME = 'trigger_name_here'
  TABLE_NAME = :table_name

  def up
    with_lock_retries do
      create_trigger_function(TRIGGER_FUNCTION_NAME, replace: true) do
        # function body
      end

      create_trigger(TABLE_NAME, TRIGGER_NAME, TRIGGER_FUNCTION_NAME, fires: 'AFTER INSERT', replace: true)
    end
  end

  def down
    with_lock_retries do
      drop_trigger(TABLE_NAME, TRIGGER_NAME, if_exists: true)
    end

    drop_function(TRIGGER_FUNCTION_NAME, if_exists: true)
  end
end

with_lock_retries를 사용하려면 disable_ddl_transaction!이 필요합니다. create_trigger 헬퍼로 트리거를 생성할 수 없는 경우 (예: 트리거가 행마다가 아니라 구문마다 실행되는 경우)에는 트리거를 생성할 때 CREATE OR REPLACE TRIGGER를 사용합니다.

마일스톤#

모든 새 마이그레이션은 다음 구문으로 마일스톤을 지정해야 합니다.

class AddFooToBar < Gitlab::Database::Migration[2.2]
  milestone '16.6'

  def change
    # Your migration here
  end
end

마이그레이션에 올바른 마일스톤을 추가하면 마이그레이션을 해당하는 GitLab 마이너 버전별로 논리적으로 나눌 수 있습니다. 그 효과는 다음과 같습니다.

  • 업그레이드 프로세스가 단순해집니다.
  • 마이그레이션의 타임스탬프에만 의존해 순서를 정할 때 생길 수 있는 마이그레이션 순서 문제를 완화합니다.

Autovacuum 랩어라운드 방지#

이는 PostgreSQL의 특수한 autovacuum 실행 모드이며, 정리(vacuum) 대상 테이블에 대한 ShareUpdateExclusiveLock이 필요합니다. 더 큰 테이블에서는 몇 시간이 걸릴 수 있으며, 이 잠금은 같은 시간에 테이블을 수정하려는 대부분의 DDL 마이그레이션과 충돌할 수 있습니다. 마이그레이션이 제때 잠금을 획득하지 못하면 실패하고 배포를 차단합니다.

배포 후 마이그레이션(PDM) 파이프라인은 테이블 중 하나에서 랩어라운드 방지 vacuum 프로세스를 감지하면 실행을 확인하고 중단할 수 있습니다. 이렇게 하려면 마이그레이션 이름에 전체 테이블 이름을 사용해야 합니다. 예를 들어 add_foreign_key_between_ci_builds_and_ci_job_artifacts는 마이그레이션을 실행하기 전에 ci_builds와 ci_job_artifacts의 vacuum 여부를 확인합니다.

마이그레이션에 충돌하는 잠금이 없다면 전체 테이블 이름을 사용하지 않아 vacuum 확인을 건너뛸 수 있습니다. 예: create_async_index_on_job_artifacts

마이그레이션 스타일 가이드

GitLab v19.4
원문 보기

요약

GitLab용 마이그레이션을 작성할 때는 이 마이그레이션이 크고 작은 수십만 개 조직에서 실행되며, 일부 조직은 데이터베이스에 수년간 쌓인 데이터를 보유하고 있다는 점을 고려해야 합니다. 또한 규모와 관계없이 업그레이드를 위해 서버를 오프라인으로 전환해야 하는 것은 대부분의 조직에 큰 부담입니다.

GitLab용 마이그레이션을 작성할 때는 이 마이그레이션이 크고 작은 수십만 개 조직에서 실행되며, 일부 조직은 데이터베이스에 수년간 쌓인 데이터를 보유하고 있다는 점을 고려해야 합니다.

또한 규모와 관계없이 업그레이드를 위해 서버를 오프라인으로 전환해야 하는 것은 대부분의 조직에 큰 부담입니다. 따라서 마이그레이션은 신중하게 작성해야 하고, 온라인 상태에서 적용할 수 있어야 하며, 아래 스타일 가이드를 준수해야 합니다.

마이그레이션은 GitLab 설치를 오프라인으로 전환하도록 요구해서는 안 됩니다. 마이그레이션은 항상 다운타임을 피하는 방식으로 작성해야 합니다. 과거에는 DOWNTIME 상수를 설정해 다운타임을 허용하는 마이그레이션을 정의하는 프로세스가 있었습니다. 오래된 마이그레이션에서 이를 볼 수 있습니다. 이 프로세스는 4년 동안 유지되었지만 한 번도 사용된 적이 없으며, 그 결과 다운타임을 피하도록 마이그레이션을 다르게 작성하는 방법을 항상 찾을 수 있다는 점을 알게 되었습니다.

마이그레이션을 작성할 때는 데이터베이스에 오래된 데이터나 불일치가 있을 수 있다는 점도 고려하고 이에 대비합니다. 데이터베이스 상태에 대한 가정은 가능한 한 적게 합니다.

GitLab 전용 코드는 이후 버전에서 바뀔 수 있으므로 여기에 의존하지 않습니다. 필요한 경우 GitLab 코드를 마이그레이션에 복사해 붙여 넣어, 이후 버전에서도 호환되도록 합니다.

적절한 마이그레이션 유형 선택#

새 마이그레이션을 추가하기 전 첫 단계는 어떤 유형이 가장 적합한지 결정하는 것입니다.

현재 만들 수 있는 마이그레이션은 수행해야 하는 작업의 종류와 완료까지 걸리는 시간에 따라 세 가지입니다.

  1. 일반 스키마 마이그레이션. 새 애플리케이션 코드가 배포되기 전에 실행되는 db/migrate의 전통적인 Rails 마이그레이션입니다 (GitLab.com에서는 Canary가 배포되기 전). 따라서 배포를 불필요하게 지연시키지 않도록 비교적 빨라야 하며, 길어도 몇 분을 넘지 않아야 합니다.

    더 오래 걸리더라도 애플리케이션이 올바르게 동작하는 데 반드시 필요한 마이그레이션은 예외입니다. 예를 들어 고유한 튜플을 강제하는 인덱스나, 애플리케이션의 핵심 부분에서 쿼리 성능에 필요한 인덱스가 여기에 해당할 수 있습니다. 그러나 마이그레이션이 허용할 수 없을 만큼 느린 경우에는 기능 플래그로 해당 기능을 보호하고 대신 배포 후 마이그레이션을 수행하는 편이 나을 수 있습니다. 그러면 마이그레이션이 끝난 뒤에 기능을 켤 수 있습니다.

    새 모델을 추가하는 마이그레이션도 이러한 일반 스키마 마이그레이션에 포함됩니다. 차이점은 마이그레이션 생성에 사용하는 Rails 명령과, 모델용 파일 하나와 모델의 스펙용 파일 하나가 추가로 생성된다는 점뿐입니다.

  2. 배포 후 마이그레이션. db/post_migrate에 있는 Rails 마이그레이션이며 GitLab.com 배포와는 독립적으로 실행됩니다. 대기 중인 배포 후 마이그레이션은 릴리스 매니저의 재량에 따라 배포 후 마이그레이션 파이프라인을 통해 매일 실행됩니다. 이러한 마이그레이션은 애플리케이션 동작에 필수적이지 않은 스키마 변경이나 길어야 몇 분 걸리는 데이터 마이그레이션에 사용할 수 있습니다. 배포 후에 실행해야 하는 스키마 변경의 일반적인 예는 다음과 같습니다.

    • 사용하지 않는 칼럼 제거와 같은 정리 작업
    • 트래픽이 많은 테이블에 필수적이지 않은 인덱스 추가
    • 생성에 오래 걸리는, 필수적이지 않은 인덱스 추가

    애플리케이션 동작에 필수적인 스키마 변경에는 이러한 마이그레이션을 사용하지 않아야 합니다. 그러한 스키마 변경을 배포 후 마이그레이션으로 수행하면 과거에 문제가 발생한 적이 있습니다(예: 이 이슈). 항상 일반 스키마 마이그레이션으로 수행해야 하며 배포 후 마이그레이션으로 실행해서는 안 되는 변경은 다음과 같습니다.

    • 새 테이블 생성. 예: create_table
    • 기존 테이블에 새 칼럼 추가. 예: add_column

    [!note] 배포 후 마이그레이션(Post-deployment migration)은 흔히 PDM으로 줄여 부릅니다.

  3. 배치 백그라운드 마이그레이션. 일반 Rails 마이그레이션이 아니라 Sidekiq job으로 실행되는 애플리케이션 코드입니다. 다만 이를 예약하는 데는 배포 후 마이그레이션을 사용합니다. 배포 후 마이그레이션의 시간 가이드라인을 초과하는 데이터 마이그레이션에만 사용합니다. 배치 백그라운드 마이그레이션은 스키마를 변경해서는 안 됩니다.

다음 다이어그램을 참고해 결정하되, 이는 하나의 도구일 뿐이며 최종 결과는 항상 적용하려는 구체적인 변경 사항에 따라 달라진다는 점을 유념합니다.

Mermaid 다이어그램 (13줄)
소스 코드 보기
graph LR
    A{Schema<br/>changed?}
    A -->|Yes| C{Critical to<br/>speed or<br/>behavior?}
    A -->|No| D{Is it fast?}
C --&gt;|Yes| H{Is it fast?}
C --&gt;|No| F[Post-deploy migration]

H --&gt;|Yes| E[Regular migration]
H --&gt;|No| I[Post-deploy migration&lt;br/&gt;+ feature flag]

D --&gt;|Yes| F[Post-deploy migration]
D --&gt;|No| G[Background migration]</code></pre></details></div>

데이터베이스 인덱스를 추가할 때 사용할 마이그레이션 유형을 고르는 방법은 사용할 마이그레이션 유형도 참고합니다.

마이그레이션에 걸리는 시간#

일반적으로 GitLab.com에서 한 번의 배포에 포함되는 모든 마이그레이션은 1시간을 넘지 않아야 합니다. 다음 가이드라인은 엄격한 규칙이 아니며, 마이그레이션 소요 시간을 최소한으로 유지하기 위해 추정한 값입니다.

Note

모든 소요 시간은 GitLab.com을 기준으로 측정해야 한다는 점을 유념합니다.

데이터베이스 마이그레이션 파이프라인의 결과에는 마이그레이션의 소요 시간 정보가 포함됩니다.

동시 작업과 백그라운드 마이그레이션을 포함한 쿼리 수준의 시간 제한은 쿼리 성능 가이드라인을 참고합니다.

마이그레이션 유형 권장 소요 시간 비고
일반 마이그레이션 <= 3 minutes 이를 지연할 수 없고, 이것이 없으면 애플리케이션 기능이나 성능이 심각하게 저하되는 변경은 유효한 예외입니다.
배포 후 마이그레이션 <= 10 minutes 스키마 변경은 백그라운드 마이그레이션에서 수행해서는 안 되므로 유효한 예외입니다. 인덱스 생성과 같은 동시 작업에는 별도의 20 minute 제한이 있습니다.
백그라운드 마이그레이션 > 10 minutes 이 마이그레이션은 더 큰 테이블에 적합하므로 정확한 시간 가이드라인을 정할 수 없습니다. 다만 개별 쿼리는 콜드 캐시 상태에서 1 second 실행 시간 미만이어야 합니다.

db:gitlabcom-database-testing 파이프라인이 인덱스 생성에 20분 넘게 걸린다고 보고하면 인덱스를 비동기로 생성합니다. 테스트 파이프라인은 데이터베이스 복제본에서 실행되므로 실제 GitLab.com 실행 시간을 낮게 추정할 수 있으며, 이 때문에 이 기준값은 의도적으로 보수적으로 잡았습니다.

대용량 테이블 제한 사항#

크기 임계값을 초과하는 테이블에 새 칼럼이나 인덱스를 추가하기 전에 대용량 테이블 제한 사항을 읽습니다.

대상 데이터베이스 결정#

GitLab은 main과 ci라는 두 개의 서로 다른 Postgres 데이터베이스에 연결합니다. 마이그레이션은 이 두 데이터베이스 중 한쪽 또는 양쪽에서 실행될 수 있으므로 이 분리가 마이그레이션에 영향을 줄 수 있습니다.

추가하는 마이그레이션에서 이를 고려해야 하는지, 그리고 어떻게 고려해야 하는지는 여러 데이터베이스를 위한 마이그레이션을 읽고 파악합니다.

일반 스키마 마이그레이션 생성#

마이그레이션을 만들려면 다음 Rails 생성기를 사용할 수 있습니다.

bundle exec rails g migration migration_name_here

이 명령은 db/migrate에 마이그레이션 파일을 생성합니다.

새 모델을 추가하는 일반 스키마 마이그레이션#

새 모델을 만들려면 다음 Rails 생성기를 사용할 수 있습니다.

bundle exec rails g model model_name_here

이 명령은 다음을 생성합니다.

  • db/migrate의 마이그레이션 파일
  • app/models의 모델 파일
  • spec/models의 스펙 파일

스키마 변경#

스키마 변경 사항은 db/structure.sql에 커밋해야 합니다. 이 파일은 bundle exec rails db:migrate를 실행할 때 Rails가 자동으로 생성하므로, 일반적으로 직접 편집하지 않아야 합니다. 마이그레이션이 테이블에 칼럼을 추가하면 해당 칼럼은 마이그레이션에 의해 그 테이블 스키마의 끝에 추가됩니다. 기존 테이블의 칼럼 순서를 수동으로 바꾸지 않습니다. 순서를 바꾸면 Rails가 생성한 db/structure.sql을 사용하는 다른 사람들과 충돌이 발생합니다.

Note

인덱스를 비동기로 생성하려면 머지 리퀘스트가 두 개 필요합니다. 작업이 끝나면 add_concurrent_index로 인덱스를 추가하는 머지 리퀘스트에 스키마 변경 사항을 커밋합니다.

GDK의 로컬 데이터베이스가 main의 스키마와 달라진 경우에는 스키마 변경 사항을 Git에 깔끔하게 커밋하기 어려울 수 있습니다. 이때는 scripts/regenerate-schema 스크립트를 사용해 추가하는 마이그레이션에 맞는 깨끗한 db/structure.sql을 다시 생성할 수 있습니다. 이 스크립트는 db/migrate 또는 db/post_migrate에 있는 모든 마이그레이션을 적용하므로, 스키마에 커밋하지 않으려는 마이그레이션이 있다면 이름을 바꾸거나 제거합니다. 브랜치가 기본 Git 브랜치를 대상으로 하지 않는 경우 TARGET 환경 변수를 설정할 수 있습니다.

# Regenerate schema against `main`
scripts/regenerate-schema

# Regenerate schema against `12-9-stable-ee`
TARGET=12-9-stable-ee scripts/regenerate-schema

scripts/regenerate-schema 스크립트가 추가 차이를 만들 수 있습니다. 이 경우 아래의 수동 절차를 사용합니다. 여기서 <migration ID>는 마이그레이션 파일의 DATETIME 부분입니다.

# Rebase against master
git rebase master

# Rollback changes
VERSION=<migration ID> bundle exec rails db:migrate:down:main

# Checkout db/structure.sql from master
git checkout origin/master db/structure.sql

# Migrate changes
VERSION=<migration ID> bundle exec rails db:migrate:main

테이블을 생성한 후에는 데이터베이스 딕셔너리 가이드에 나온 단계에 따라 데이터베이스 딕셔너리에 추가해야 합니다.

마이그레이션 체크섬 파일#

마이그레이션이 처음 실행되면 마이그레이션의 타임스탬프로 생성한 SHA256이 담긴 새 migration checksum file이 db/schema_migrations에 만들어집니다. 이 새 파일의 이름은 마이그레이션 파일 이름의 타임스탬프 부분과 같습니다. 예를 들어 db/schema_migrations/20241021120146입니다. 이 파일의 내용은 타임스탬프 부분의 SHA256입니다. 예를 들면 다음과 같습니다.

$ echo -n "20241021120146" | sha256sum
7a3e382a6e5564bfa7004bca1a357a910b151e7399c6466113daf01526d97470  -

SHA256은 파일에 고유한 내용을 더해, Git의 이름 변경 감지가 이 파일들을 별개의 파일로 인식하게 합니다.

이 migration checksum file은 마이그레이션이 성공적으로 실행되어 그 결과가 db/structure.sql에 기록되었음을 나타냅니다. 이 파일이 있으면 같은 마이그레이션이 두 번 실행되지 않으므로, 새 마이그레이션을 추가하는 머지 리퀘스트에 이 파일을 반드시 포함해야 합니다.

db/schema_migrations 디렉터리에 대한 자세한 내용은 Development change: Database schema version handling outside of structure.sql을 참고합니다.

마이그레이션 체크섬 파일을 최신 상태로 유지#

  • 새 마이그레이션을 만들면 rake db:migrate를 실행해 마이그레이션을 실행하고 이에 해당하는 db/schema_migration/<timestamp> 체크섬 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.
  • 마이그레이션을 삭제하면 이에 해당하는 db/schema_migration/<timestamp> 체크섬 파일을 제거합니다.
  • 마이그레이션의 _타임스탬프 부분_이 바뀌면 이에 해당하는 db/schema_migration/<timestamp> 체크섬 파일을 제거하고 rake db:migrate를 실행해 새 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.
  • 마이그레이션의 내용이 바뀌어도 db/schema_migration/<timestamp> 체크섬 파일은 변경할 필요가 없습니다.

다운타임 방지#

"마이그레이션에서 다운타임 방지" 문서는 다음과 같은 다양한 데이터베이스 작업을 설명합니다.

또한 다운타임 없이 이러한 작업을 수행하는 방법도 설명합니다.

되돌릴 수 있는 마이그레이션#

마이그레이션은 되돌릴 수 있어야 합니다. 취약점이나 버그가 발생했을 때 다운그레이드할 수 있어야 하므로 이는 매우 중요합니다.

Note

GitLab 프로덕션 환경에서는 문제가 발생하면 db:rollback으로 마이그레이션을 롤백하는 대신 롤포워드 전략을 사용합니다. GitLab Self-Managed에서는 업그레이드 프로세스를 시작하기 전에 만든 백업을 복원할 것을 권장합니다. down 메서드는 주로 개발 환경에서 사용합니다. 예를 들어 개발자가 커밋이나 브랜치를 전환할 때 로컬의 structure.sql 파일 사본과 데이터베이스가 일관된 상태인지 확인하려는 경우입니다.

마이그레이션에는 되돌릴 수 있는지를 어떻게 테스트했는지 설명하는 주석을 추가합니다.

일부 마이그레이션은 되돌릴 수 없습니다. 예를 들어 일부 데이터 마이그레이션은 마이그레이션 이전 데이터베이스 상태에 대한 정보가 사라지므로 되돌릴 수 없습니다. 이 경우에도 up 메서드가 수행한 변경을 되돌릴 수 없는 이유를 설명하는 주석과 함께 down 메서드를 만들어, 마이그레이션 중에 수행된 변경은 되돌릴 수 없더라도 마이그레이션 자체는 되돌릴 수 있도록 해야 합니다.

def down
  # no-op

  # comment explaining why changes performed by `up` cannot be reversed.
end

이러한 마이그레이션은 본질적으로 위험하며, 리뷰를 위해 마이그레이션을 준비할 때 추가 조치가 필요합니다.

원자성과 트랜잭션#

기본적으로 마이그레이션은 단일 트랜잭션입니다. 트랜잭션은 마이그레이션 시작 시 열리고 모든 단계가 처리된 후 커밋됩니다.

마이그레이션을 단일 트랜잭션으로 실행하면 단계 중 하나가 실패할 경우 어떤 단계도 실행되지 않으므로 데이터베이스가 유효한 상태로 유지됩니다. 따라서 다음 중 하나를 따릅니다.

  • 모든 마이그레이션을 하나의 단일 트랜잭션 마이그레이션에 넣습니다.
  • 필요한 경우 대부분의 작업을 하나의 마이그레이션에 넣고, 단일 트랜잭션으로 수행할 수 없는 단계는 별도의 마이그레이션으로 만듭니다.

예를 들어 빈 테이블을 만들고 이 테이블의 인덱스를 생성해야 한다면 일반적인 단일 트랜잭션 마이그레이션과 Rails의 기본 스키마 구문인 add_index를 사용해야 합니다. 이 작업은 차단 작업이지만 테이블이 아직 사용되지 않아 레코드가 하나도 없으므로 문제가 되지 않습니다.

Note

일반적으로 서브트랜잭션은 허용되지 않습니다. 필요한 경우 단일 트랜잭션의 무거운 작업에 설명된 대로 여러 개의 별도 트랜잭션을 사용합니다.

단일 트랜잭션의 무거운 작업#

단일 트랜잭션 마이그레이션을 사용하면 트랜잭션이 마이그레이션이 진행되는 동안 데이터베이스 연결을 점유하므로, 마이그레이션의 작업이 너무 오래 걸리지 않도록 해야 합니다. 일반적으로 트랜잭션은 빠르게 실행되어야 합니다. 이를 위해 마이그레이션에서 실행하는 각 쿼리에 최대 쿼리 시간 제한을 지킵니다.

단일 트랜잭션 마이그레이션이 끝나기까지 오래 걸리는 경우 몇 가지 선택지가 있습니다. 어느 경우든 마이그레이션에 걸리는 시간에 따라 적절한 마이그레이션 유형을 선택해야 합니다.

  • 마이그레이션을 여러 개의 단일 트랜잭션 마이그레이션으로 나눕니다.

  • disable_ddl_transaction! 사용으로 여러 트랜잭션을 사용합니다.

  • 구문 타임아웃과 잠금 타임아웃 설정을 조정한 후 단일 트랜잭션 마이그레이션을 계속 사용합니다. 무거운 워크로드가 트랜잭션의 보장을 사용해야 한다면 마이그레이션이 타임아웃 제한에 걸리지 않고 실행되는지 확인해야 합니다. 같은 조언이 단일 트랜잭션 마이그레이션과 개별 트랜잭션 모두에 적용됩니다.

    • 구문 타임아웃: 구문 타임아웃은 GitLab.com 프로덕션 데이터베이스에서 15s로 설정되어 있지만 인덱스 생성에는 종종 15초보다 오래 걸립니다. add_concurrent_index를 비롯한 기존 헬퍼를 사용하면 필요에 따라 구문 타임아웃이 자동으로 해제됩니다. 드물게는 disable_statement_timeout 사용으로 타임아웃 제한을 직접 설정해야 할 수도 있습니다.
Note

마이그레이션을 실행할 때는 statement_timeout, lock_wait_timeout 같은 설정을 제어하기 위해 PgBouncer를 거치지 않고 기본 데이터베이스에 직접 연결합니다.

구문 타임아웃 제한을 일시적으로 해제#

마이그레이션 헬퍼 disable_statement_timeout을 사용하면 구문 타임아웃을 트랜잭션별 또는 연결별로 일시적으로 0으로 설정할 수 있습니다.

  • CREATE INDEX CONCURRENTLY처럼 구문이 명시적 트랜잭션 안에서 실행되는 것을 지원하지 않는 경우에는 연결별 옵션을 사용합니다.
  • ALTER TABLE ... VALIDATE CONSTRAINT처럼 구문이 명시적 트랜잭션 블록을 지원하는 경우에는 트랜잭션별 옵션을 사용해야 합니다.

대부분의 마이그레이션 헬퍼가 필요할 때 이미 내부적으로 이를 사용하므로 disable_statement_timeout을 사용할 일은 드뭅니다. 예를 들어 인덱스 생성에는 보통 15초보다 오래 걸리는데, 15초는 GitLab.com 프로덕션 데이터베이스에 설정된 기본 구문 타임아웃입니다. 헬퍼 add_concurrent_index는 disable_statement_timeout에 전달된 블록 안에서 인덱스를 생성하여 연결별로 구문 타임아웃을 해제합니다.

마이그레이션에서 원시 SQL 구문을 작성하는 경우 disable_statement_timeout을 직접 사용해야 할 수 있습니다. 이때는 데이터베이스 리뷰어 및 메인테이너와 상의합니다.

트랜잭션으로 감싼 마이그레이션 비활성화#

ActiveRecord 메서드인 disable_ddl_transaction!을 사용하면 마이그레이션을 단일 트랜잭션으로 실행하지 않도록 선택할 수 있습니다. 다른 데이터베이스 시스템에서는 이 메서드가 다른 이름으로 불리며 결과도 다를 수 있습니다. GitLab에서는 PostgreSQL만 사용합니다. disable_ddl_transaction!은 항상 다음과 같은 의미로 읽어야 합니다.

"이 마이그레이션을 단일 PostgreSQL 트랜잭션으로 실행하지 않습니다. PostgreSQL 트랜잭션은 필요할 때, 필요한 경우에만 직접 엽니다."

Note

명시적인 PostgreSQL 트랜잭션 .transaction(또는 BEGIN; COMMIT;)을 사용하지 않더라도 모든 SQL 구문은 여전히 트랜잭션으로 실행됩니다. PostgreSQL 트랜잭션 문서를 참고합니다.

GitLab에서는 disable_ddl_transaction!을 사용한 마이그레이션을 비트랜잭션 마이그레이션이라고 부르기도 했습니다. 이는 마이그레이션이 단일 트랜잭션으로 실행되지 않는다는 뜻일 뿐이었습니다.

disable_ddl_transaction!은 언제 사용해야 할까요? 대부분의 경우 기존 RuboCop 규칙이나 마이그레이션 헬퍼가 disable_ddl_transaction!을 사용해야 하는지 감지할 수 있습니다. 마이그레이션에서 disable_ddl_transaction!을 사용해야 할지 확신이 서지 않으면 사용하지 않고, RuboCop 규칙과 데이터베이스 리뷰의 안내를 따릅니다.

PostgreSQL이 명시적 트랜잭션 밖에서 작업을 실행하도록 요구할 때 disable_ddl_transaction!을 사용합니다.

  • 이러한 작업의 가장 대표적인 예는 CREATE INDEX CONCURRENTLY 명령입니다. PostgreSQL은 차단 버전(CREATE INDEX)을 트랜잭션 안에서 실행하도록 허용합니다. CREATE INDEX와 달리 CREATE INDEX CONCURRENTLY는 트랜잭션 밖에서 실행해야 합니다. 따라서 마이그레이션이 CREATE INDEX CONCURRENTLY 구문 하나만 실행하더라도 disable_ddl_transaction!을 사용해 단일 트랜잭션 실행을 해제해야 합니다. 헬퍼 add_concurrent_index를 사용하려면 disable_ddl_transaction!이 필요한 것도 이 때문입니다. CREATE INDEX CONCURRENTLY는 일반적인 경우라기보다 예외에 가깝습니다.

어떤 이유로든 마이그레이션에서 여러 트랜잭션을 실행해야 할 때 disable_ddl_transaction!을 사용합니다. 여러 트랜잭션을 사용하는 대부분의 경우는 느린 트랜잭션 하나를 실행하는 것을 피하기 위해서입니다.

  • 예를 들어 많은 양의 데이터를 삽입, 업데이트 또는 삭제(DML)할 때는 배치로 수행해야 합니다. 배치마다 작업을 묶어야 한다면 배치를 처리할 때 트랜잭션 블록을 명시적으로 열 수 있습니다. 상당히 큰 워크로드에는 배치 백그라운드 마이그레이션 사용을 고려합니다.

마이그레이션 헬퍼가 요구할 때 disable_ddl_transaction!을 사용합니다. 여러 마이그레이션 헬퍼는 트랜잭션을 언제 어떻게 열지 정밀하게 제어해야 하므로 disable_ddl_transaction!과 함께 실행해야 합니다.

  • CREATE INDEX CONCURRENTLY와 달리 외래 키는 트랜잭션 안에서 추가_할 수 있습니다_. 그러나 PostgreSQL에는 CREATE INDEX CONCURRENTLY와 비슷한 옵션이 없습니다. 대신 헬퍼 add_concurrent_foreign_key가 자체 트랜잭션을 열어 소스 테이블과 대상 테이블을 잠급니다. 이렇게 하면 외래 키를 추가하고 검증하는 동안 잠금이 최소화됩니다.
  • 앞서 안내했듯이 확신이 서지 않으면 disable_ddl_transaction!을 사용하지 않고 RuboCop 검사를 위반하는지 확인합니다.

마이그레이션이 실제로 PostgreSQL 데이터베이스를 다루지 않거나 여러 PostgreSQL 데이터베이스를 다룰 때 disable_ddl_transaction!을 사용합니다.

  • 예를 들어 마이그레이션이 Redis 서버를 대상으로 할 수 있습니다. 원칙적으로 PostgreSQL 트랜잭션 안에서는 외부 서비스와 상호작용할 수 없습니다.
  • 트랜잭션은 하나의 데이터베이스 연결에 사용됩니다. 마이그레이션이 ci와 main 데이터베이스처럼 여러 데이터베이스를 대상으로 한다면 여러 데이터베이스를 위한 마이그레이션을 따릅니다.

명명 규칙#

데이터베이스 객체(테이블, 인덱스, 뷰 등)의 이름은 소문자여야 합니다. 이름을 소문자로 지으면 따옴표 없는 이름을 사용하는 쿼리에서 오류가 발생하지 않습니다.

칼럼 이름은 ActiveRecord의 스키마 규칙과 일관되게 유지합니다.

사용자 지정 인덱스 및 제약 조건 이름은 제약 조건 명명 규칙 가이드라인을 따라야 합니다.

긴 인덱스 이름 줄이기#

PostgreSQL은 칼럼이나 인덱스 이름 같은 식별자의 길이를 제한합니다. 칼럼 이름은 보통 문제가 되지 않지만 인덱스 이름은 더 길어지는 경향이 있습니다. 너무 긴 이름을 줄이는 몇 가지 방법은 다음과 같습니다.

  • index_ 대신 i_ 접두사를 붙입니다.
  • 중복되는 접두사를 생략합니다. 예를 들어 index_vulnerability_findings_remediations_on_vulnerability_remediation_id는 index_vulnerability_findings_remediations_on_remediation_id가 됩니다.
  • 칼럼 대신 index_users_for_unconfirmation_notification처럼 인덱스의 목적을 지정합니다.

마이그레이션 타임스탬프 경과 기간#

마이그레이션 파일 이름의 타임스탬프 부분은 마이그레이션이 실행되는 순서를 결정합니다. 다음 두 가지 사이에 대략적인 상관관계를 유지하는 것이 중요합니다.

  1. 마이그레이션이 GitLab 코드베이스에 추가된 시점
  2. 마이그레이션 자체의 타임스탬프

새 마이그레이션의 타임스탬프는 이전 필수 업그레이드 중단점보다 앞서서는 안 됩니다. 마이그레이션은 가끔 스쿼시되며, 타임스탬프가 이전 필수 중단점보다 앞선 마이그레이션이 추가되면 이슈 408304에서 발생한 것과 같은 문제가 생길 수 있습니다.

예를 들어 현재 GitLab 16.0을 대상으로 개발 중이라면 이전 필수 중단점은 15.11입니다. 15.11은 2023년 4월 23일에 릴리스되었습니다. 따라서 허용되는 최소 타임스탬프는 20230424000000입니다.

모범 사례#

위 내용은 엄격한 규칙으로 간주해야 하지만, 마지막 필수 중단점 이후 얼마나 시간이 지났는지와 관계없이 마이그레이션 타임스탬프를 업스트림에 머지될 것으로 예상되는 날짜로부터 3주 이내로 유지하는 것이 모범 사례입니다.

마이그레이션 타임스탬프를 업데이트하려면 다음을 수행합니다.

  1. ci와 main 데이터베이스에서 마이그레이션을 다운 마이그레이션합니다.

    rake db:migrate:down:main VERSION=<timestamp>
    rake db:migrate:down:ci VERSION=<timestamp>
    
  2. 마이그레이션 파일을 삭제합니다.

  3. 마이그레이션 스타일 가이드에 따라 마이그레이션을 다시 만듭니다.

또는 다음 스크립트를 사용해 모든 마이그레이션 타임스탬프를 새로 고칠 수 있습니다.

scripts/refresh-migrations-timestamps

이 스크립트는 다음을 수행합니다.

  1. 모든 마이그레이션 타임스탬프를 현재 시각으로 업데이트합니다.
  2. 마이그레이션의 상대적인 순서를 유지합니다.
  3. 파일 이름과 마이그레이션 클래스 내부의 타임스탬프를 모두 업데이트합니다.
  4. 일반 마이그레이션과 배포 후 마이그레이션을 모두 처리합니다.
Note

마이그레이션이 오랫동안(3주 초과) 리뷰 중이거나 오래된 마이그레이션 브랜치를 리베이스할 때는 머지하기 전에 이 스크립트를 실행합니다.

마이그레이션 헬퍼와 버전 관리#

데이터베이스 마이그레이션의 여러 일반적인 패턴에 사용할 수 있는 다양한 헬퍼 메서드가 있습니다. 이러한 헬퍼는 Gitlab::Database::MigrationHelpers 및 관련 모듈에서 찾을 수 있습니다.

시간이 지나도 헬퍼의 동작을 바꿀 수 있도록 마이그레이션 헬퍼에는 버전 관리 체계를 적용합니다. 이를 통해 이미 존재하는 마이그레이션에서는 헬퍼의 동작을 유지하고 새 마이그레이션에서만 동작을 바꿀 수 있습니다.

이를 위해 모든 데이터베이스 마이그레이션은 "버전이 있는" 클래스인 Gitlab::Database::Migration을 상속해야 합니다. 새 마이그레이션에서는 최신 버전의 마이그레이션 헬퍼를 사용하도록 최신 버전을 사용해야 합니다 (최신 버전은 Gitlab::Database::Migration::MIGRATION_CLASSES에서 확인할 수 있습니다).

다음 예시에서는 마이그레이션 클래스의 버전 2.1을 사용합니다.

class TestMigration < Gitlab::Database::Migration[2.1]
  def change
  end
end

Gitlab::Database::MigrationHelpers를 마이그레이션에 직접 포함하지 않습니다. 대신 최신 버전의 Gitlab::Database::Migration을 사용합니다. 이 클래스는 최신 버전의 마이그레이션 헬퍼를 자동으로 노출합니다.

데이터베이스 잠금 획득 시 재시도 메커니즘#

데이터베이스 스키마를 변경할 때는 헬퍼 메서드로 DDL(Data Definition Language) 구문을 호출합니다. 경우에 따라 이러한 DDL 구문에는 특정 데이터베이스 잠금이 필요합니다.

예시:

def change
  remove_column :users, :full_name, :string
end

이 마이그레이션을 실행하려면 users 테이블에 대한 배타적 잠금이 필요합니다. 테이블에 다른 프로세스가 동시에 접근하고 수정하는 경우 잠금을 획득하는 데 시간이 걸릴 수 있습니다. 잠금 요청은 큐에서 대기하며, 큐에 들어간 뒤에는 users 테이블에 대한 다른 쿼리도 차단할 수 있습니다.

PostgreSQL 잠금에 대한 자세한 내용은 Explicit Locking을 참고합니다.

안정성을 위해 GitLab.com에는 짧은 statement_timeout이 설정되어 있습니다. 마이그레이션이 호출되면 모든 데이터베이스 쿼리는 정해진 시간 안에 실행되어야 합니다. 최악의 경우 요청이 잠금 큐에서 대기하면서 설정된 구문 타임아웃 시간 동안 다른 쿼리를 차단하다가 canceling statement due to statement timeout 오류로 실패합니다.

이 문제는 애플리케이션 업그레이드 프로세스를 실패하게 만들 수 있고, 테이블에 잠시 접근할 수 없게 되므로 애플리케이션 안정성 문제로 이어질 수도 있습니다.

데이터베이스 마이그레이션의 신뢰성과 안정성을 높이기 위해 GitLab 코드베이스는 lock_timeout 설정과 시도 사이의 대기 시간을 달리하며 작업을 재시도하는 메서드를 제공합니다. 필요한 잠금을 더 짧게 여러 번 획득하려고 시도하면 그 사이에 데이터베이스가 다른 구문을 처리할 수 있습니다.

non_transactional 마이그레이션을 사용할 때 with_lock_retries 메서드를 사용하면 마이그레이션 안에서 실행되는 코드 블록의 잠금 획득 재시도와 타임아웃 설정을 명시적으로 제어할 수 있습니다.

트랜잭션 마이그레이션#

일반 마이그레이션은 전체 마이그레이션을 트랜잭션으로 실행합니다. 잠금 재시도 메커니즘은 기본적으로 활성화되어 있습니다(disable_ddl_transaction!을 사용하는 경우 제외).

이에 따라 마이그레이션의 잠금 타임아웃이 제어됩니다. 또한 타임아웃 안에 잠금을 얻지 못하면 전체 마이그레이션을 재시도할 수도 있습니다.

마이그레이션이 서로 다른 객체에 대해 여러 잠금을 획득해야 하는 경우가 가끔 있습니다. 카탈로그 비대화를 방지하려면 DDL을 수행하기 전에 그러한 잠금을 모두 명시적으로 요청합니다. 더 나은 전략은 한 번에 잠금 하나만 획득하면 되도록 마이그레이션을 나누는 것입니다.

같은 테이블에 대한 여러 변경#

잠금 재시도 방식을 활성화하면 모든 작업이 하나의 트랜잭션으로 감싸집니다. 잠금을 얻은 뒤에는 나중에 다른 잠금을 얻으려고 시도하기보다 트랜잭션 안에서 가능한 한 많은 작업을 수행해야 합니다. 블록 안에서 오래 걸리는 데이터베이스 구문을 실행할 때는 주의합니다. 획득한 잠금은 트랜잭션(블록)이 끝날 때까지 유지되며, 잠금 유형에 따라 다른 데이터베이스 작업을 차단할 수 있습니다.

def up
  add_column :users, :full_name, :string
  add_column :users, :bio, :string
end

def down
  remove_column :users, :full_name
  remove_column :users, :bio
end

칼럼의 기본값 변경#

여러 릴리스에 걸친 프로세스를 따르지 않으면 칼럼 기본값을 변경할 때 애플리케이션 다운타임이 발생할 수 있습니다. 자세한 내용은 칼럼 기본값 변경 시 다운타임 방지를 참고합니다.

def up
  change_column_default :merge_requests, :lock_version, from: nil, to: 0
end

def down
  change_column_default :merge_requests, :lock_version, from: 0, to: nil
end

외래 키가 두 개인 새 테이블 생성#

트랜잭션당 외래 키는 하나만 생성해야 합니다. 외래 키 제약 조건을 추가하려면 참조되는 테이블에 대한 SHARE ROW EXCLUSIVE 잠금이 필요하며, 같은 트랜잭션에서 여러 테이블을 잠그는 것은 피해야 하기 때문입니다.

이를 위해 마이그레이션이 세 개 필요합니다.

  1. 외래 키 없이 테이블을 생성합니다(인덱스 포함).
  2. 첫 번째 테이블에 외래 키를 추가합니다.
  3. 두 번째 테이블에 외래 키를 추가합니다.

테이블 생성:

def up
  create_table :imports do |t|
    t.bigint :project_id, null: false
    t.bigint :user_id, null: false
    t.string :jid, limit: 255

    t.index :project_id
    t.index :user_id
  end
end

def down
  drop_table :imports
end

projects에 외래 키 추가:

이 경우 add_concurrent_foreign_key 메서드를 사용할 수 있습니다. 이 헬퍼 메서드에는 잠금 재시도가 내장되어 있기 때문입니다.

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :project_id
  end
end

users에 외래 키 추가:

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :users, column: :user_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :user_id
  end
end

2단계 외래 키 검증으로 잠금 경합 최소화#

트래픽이 많은 테이블이거나 배포 중 잠금 경합을 일으킬 수 있는 외래 키를 추가할 때는 외래 키 생성과 검증을 서로 다른 마이그레이션으로 분리하는 것을 고려합니다. 이는 파티션 테이블에서 특히 중요합니다. 파티션 테이블에서는 부모 테이블에 외래 키를 추가하기 전에 각 파티션에 외래 키를 개별적으로 추가해야 합니다.

  1. 첫 번째 마이그레이션: 배포 중 쓰기가 차단되지 않도록 validate: false로 외래 키를 추가합니다
  2. 두 번째 마이그레이션: prepare_async_foreign_key_validation을 사용해 외래 키를 비동기로 검증합니다
  3. 세 번째 마이그레이션: 검증이 완료된 후 (파티션 테이블의 경우) 부모 외래 키를 추가합니다

add_concurrent_foreign_key는 이미 내부적으로 2단계 검증(검증 없이 추가한 다음 검증)을 수행하지만, 이 단계를 서로 다른 마이그레이션으로 분리하면 검증을 배포 기간 밖에서 수행할 수 있어 배포 시간과 위험이 줄어들므로 배포에 유연성이 생깁니다.

이 접근 방식은 잠금 경합으로 인한 배포 차단을 최소화합니다. 이 사례와 같은 프로덕션 인시던트에서는 중요한 배포 기간 중 외래 키 검증이 예상보다 오래 걸렸습니다.

비트랜잭션 마이그레이션에서의 사용#

disable_ddl_transaction!로 트랜잭션 마이그레이션을 비활성화한 경우에만 with_lock_retries 헬퍼로 개별 단계 시퀀스를 보호할 수 있습니다. 이 헬퍼는 주어진 블록을 실행하기 위해 트랜잭션을 엽니다.

사용자 지정 RuboCop 규칙이 잠금 재시도 블록 안에 허용된 메서드만 넣을 수 있도록 보장합니다.

disable_ddl_transaction!

def up
  with_lock_retries do
    add_column(:users, :name, :text, if_not_exists: true)
  end

  add_text_limit :users, :name, 255 # Includes constraint validation (full table scan)
end

RuboCop 규칙은 일반적으로 아래에 나열된 표준 Rails 마이그레이션 메서드를 허용합니다. 다음 예시는 RuboCop 위반을 일으킵니다.

disable_ddl_transaction!

def up
  with_lock_retries do
    add_concurrent_index :users, :name
  end
end

헬퍼 메서드를 사용할 시점#

with_lock_retries 헬퍼 메서드는 실행이 이미 열린 트랜잭션 안에 있지 않을 때만 사용할 수 있습니다(PostgreSQL 서브트랜잭션은 사용하지 않는 것이 좋습니다). 표준 Rails 마이그레이션 헬퍼 메서드와 함께 사용할 수 있습니다. 마이그레이션 헬퍼를 둘 이상 호출하는 것은 같은 테이블에서 실행되는 한 문제가 되지 않습니다.

데이터베이스 마이그레이션에 트래픽이 많은 테이블 중 하나가 관련된 경우 with_lock_retries 헬퍼 메서드를 사용하는 것이 좋습니다.

변경 예시:

  • add_foreign_key / remove_foreign_key
  • add_column / remove_column
  • change_column_default
  • create_table / drop_table

with_lock_retries 메서드는 change 메서드 안에서 사용할 수 없습니다. 마이그레이션을 되돌릴 수 있게 하려면 up과 down 메서드를 직접 정의해야 합니다.

헬퍼 메서드의 동작 방식#

  1. 50회 반복합니다.
  2. 각 반복마다 미리 구성된 lock_timeout을 설정합니다.
  3. 주어진 블록(remove_column)을 실행합니다.
  4. LockWaitTimeout 오류가 발생하면 미리 구성된 sleep_time 동안 대기한 뒤 블록을 재시도합니다.
  5. 오류가 발생하지 않으면 현재 반복에서 블록이 성공적으로 실행된 것입니다.

자세한 내용은 Gitlab::Database::WithLockRetries 클래스를 확인합니다. with_lock_retries 헬퍼 메서드는 Gitlab::Database::MigrationHelpers 모듈에 구현되어 있습니다.

최악의 경우 이 메서드는 다음과 같이 동작합니다.

  • 블록을 40분에 걸쳐 최대 50회 실행합니다.
    • 대부분의 시간은 각 반복 후 미리 구성된 대기 시간에 소요됩니다.
  • 50번째 재시도 이후에는 표준 마이그레이션 호출과 마찬가지로 lock_timeout 없이 블록을 실행합니다.
  • 잠금을 획득할 수 없으면 마이그레이션이 statement timeout 오류로 실패합니다.

users 테이블에 접근하는 매우 오래 실행되는 트랜잭션(40분 이상)이 있으면 마이그레이션이 실패할 수 있습니다.

SQL 수준의 잠금 재시도 방식#

이 섹션에서는 lock_timeout의 사용을 보여 주는 간단한 SQL 예시를 제공합니다. 주어진 스니펫을 여러 psql 세션에서 실행하며 따라 해 볼 수 있습니다.

테이블에 칼럼을 추가하기 위해 테이블을 변경할 때는 대부분의 잠금 유형과 충돌하는 AccessExclusiveLock이 테이블에 필요합니다. 대상 테이블이 매우 바쁜 테이블이면 칼럼을 추가하는 트랜잭션이 AccessExclusiveLock을 적시에 획득하지 못할 수 있습니다.

트랜잭션이 테이블에 행을 삽입하려고 한다고 가정합니다.

-- Transaction 1
BEGIN;
INSERT INTO my_notes (id) VALUES (1);

이 시점에 Transaction 1은 my_notes에 대한 RowExclusiveLock을 획득했습니다. Transaction 1은 커밋하거나 중단하기 전에 더 많은 구문을 실행할 수 있습니다. my_notes를 다루는 비슷한 동시 트랜잭션이 더 있을 수도 있습니다.

트랜잭션 마이그레이션이 잠금 재시도 헬퍼를 사용하지 않고 테이블에 칼럼을 추가하려고 한다고 가정합니다.

-- Transaction 2
BEGIN;
ALTER TABLE my_notes ADD COLUMN title text;

Transaction 2는 my_notes 테이블에 대한 AccessExclusiveLock을 획득할 수 없어 차단됩니다. Transaction 1이 아직 실행 중이고 my_notes에 대한 RowExclusiveLock을 보유하고 있기 때문입니다.

더 해로운 영향은 Transaction 2가 AccessExclusiveLock을 획득하기 위해 큐에서 대기하기 때문에 평소에는 Transaction 1과 충돌하지 않을 트랜잭션까지 차단된다는 점입니다. 정상적인 상황에서는 다른 트랜잭션이 Transaction 1과 동시에 같은 테이블 my_notes에서 읽고 쓰려고 해도 해당 트랜잭션은 통과합니다. 읽기와 쓰기에 필요한 잠금이 Transaction 1이 보유한 RowExclusiveLock과 충돌하지 않기 때문입니다. 그러나 AccessExclusiveLock 획득 요청이 큐에 들어가면, Transaction 1과 나란히 동시에 실행될 수 있는 요청이더라도 테이블에 대한 충돌하는 잠금을 요구하는 이후의 요청은 차단됩니다.

with_lock_retries를 사용하면 Transaction 2는 지정된 시간 안에 잠금을 획득하지 못한 뒤 빠르게 타임아웃되고 다른 트랜잭션이 진행되도록 허용합니다.

-- Transaction 2 (version with lock timeout)
BEGIN;
SET LOCAL lock_timeout to '100ms'; -- added by the lock retry helper.
ALTER TABLE my_notes ADD COLUMN title text;

잠금 재시도 헬퍼는 성공할 때까지 같은 트랜잭션을 서로 다른 시간 간격으로 반복해서 시도합니다.

SET LOCAL은 매개변수(lock_timeout) 변경의 범위를 트랜잭션으로 한정합니다.

인덱스 제거#

테이블이 비어 있지 않은 상태에서 인덱스를 제거할 때는 일반 remove_index 메서드 대신 remove_concurrent_index 메서드를 사용해야 합니다. remove_concurrent_index 메서드는 인덱스를 동시에 삭제하므로 잠금이 필요하지 않고 다운타임도 필요하지 않습니다. 이 메서드를 사용하려면 다음과 같이 마이그레이션 클래스 본문에서 disable_ddl_transaction! 메서드를 호출해 단일 트랜잭션 모드를 비활성화해야 합니다.

class MyMigration < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  INDEX_NAME = 'index_name'

  def up
    remove_concurrent_index :table_name, :column_name, name: INDEX_NAME
  end
end

Grafana로 인덱스가 사용되지 않는지 확인할 수 있습니다.

sum by (type)(rate(pg_stat_user_indexes_idx_scan{env="gprd", indexrelname="INSERT INDEX NAME HERE"}[30d]))

인덱스를 제거하기 전에 인덱스가 존재하는지 확인할 필요는 없지만, 제거할 인덱스의 이름은 반드시 지정해야 합니다. 이름은 remove_index 또는 remove_concurrent_index의 해당 형식에 옵션으로 이름을 전달하거나, remove_concurrent_index_by_name 메서드를 사용해 지정할 수 있습니다. 올바른 인덱스가 제거되도록 이름을 명시적으로 지정하는 것이 중요합니다.

작은 테이블(빈 테이블이거나 레코드가 1,000개 미만인 테이블)의 경우 disable_ddl_transaction!이 필요하지 않은 다른 작업과 결합해 단일 트랜잭션 마이그레이션에서 remove_index를 사용하는 것이 좋습니다.

인덱스 비활성화#

인덱스 비활성화는 안전한 작업이 아닙니다.

인덱스 추가#

인덱스를 추가하기 전에 인덱스가 필요한지 검토합니다. 데이터베이스 인덱스 추가 가이드에는 인덱스가 필요한지 판단하는 데 도움이 되는 더 자세한 내용과 인덱스 추가 모범 사례가 있습니다.

고유 인덱스#

Cells 아키텍처의 고유 인덱스 요구 사항에 대한 자세한 내용은 Cells의 고유 제약 조건을 참고합니다.

인덱스 존재 여부 테스트#

인덱스의 유무에 따른 조건부 로직이 마이그레이션에 필요하다면 인덱스 이름으로 해당 인덱스의 존재 여부를 테스트해야 합니다. 이렇게 하면 Rails가 인덱스 정의를 비교하는 방식 때문에 생기는 문제를 피할 수 있으며, 그러한 문제는 예기치 않은 결과로 이어질 수 있습니다.

자세한 내용은 데이터베이스 인덱스 추가 가이드를 검토합니다.

NOT NULL 제약 조건#

자세한 내용은 NOT NULL 제약 조건 스타일 가이드를 참고합니다.

기본값이 있는 칼럼 추가#

GitLab의 최소 버전이 PostgreSQL 11이므로 기본값이 있는 칼럼을 추가하는 일이 훨씬 쉬워졌으며, 모든 경우에 표준 add_column 헬퍼를 사용해야 합니다.

PostgreSQL 11 이전에는 기본값이 있는 칼럼을 추가하면 테이블 전체를 다시 써야 했으므로 문제가 되었습니다.

null을 허용하지 않는 칼럼의 기본값 제거#

null을 허용하지 않는 칼럼을 추가하고 기본값을 사용해 기존 데이터를 채웠다면 적어도 애플리케이션 코드가 업데이트될 때까지는 그 기본값을 유지해야 합니다. 같은 마이그레이션에서 기본값을 제거할 수는 없습니다. 마이그레이션은 모델 코드가 업데이트되기 전에 실행되며 모델은 이전 스키마 캐시를 가지고 있어 이 칼럼을 알지 못하고 값을 설정할 수도 없기 때문입니다. 이 경우 다음을 권장합니다.

  1. 표준 마이그레이션에서 기본값이 있는 칼럼을 추가합니다.
  2. 배포 후 마이그레이션에서 기본값을 제거합니다.

배포 후 마이그레이션은 애플리케이션이 재시작된 후에 실행되므로 새 칼럼이 인식된 상태임을 보장합니다.

칼럼 기본값 변경#

change_column_default로 칼럼의 기본값을 변경하는 것이 큰 테이블에서는 비용이 많이 들고 서비스에 지장을 주는 작업이라고 생각할 수 있지만 실제로는 그렇지 않습니다.

다음 마이그레이션을 예로 들어 보겠습니다.

class DefaultRequestAccessGroups < Gitlab::Database::Migration[2.1]
  def change
    change_column_default(:namespaces, :request_access_enabled, from: false, to: true)
  end
end

위 마이그레이션은 가장 큰 테이블 중 하나인 namespaces의 칼럼 기본값을 변경합니다. 이는 다음과 같이 옮길 수 있습니다.

ALTER TABLE namespaces
ALTER COLUMN request_access_enabled
SET DEFAULT false

이 경우 기본값이 이미 존재하고 request_access_enabled 칼럼의 메타데이터만 변경하므로, namespaces 테이블의 기존 레코드를 모두 다시 쓰지는 않습니다. 기본값이 있는 새 칼럼을 만들 때만 모든 레코드를 다시 쓰게 됩니다.

Note

더 빠른 null이 아닌 기본값을 사용하는 ALTER TABLE ADD COLUMN이 PostgreSQL 11.0에서 도입되어, 기본값이 있는 새 칼럼을 추가할 때 테이블을 다시 쓸 필요가 없어졌습니다.

위에서 언급한 이유로 disable_ddl_transaction! 없이 단일 트랜잭션 마이그레이션에서 change_column_default를 사용해도 안전합니다.

기존 칼럼 업데이트#

기존 칼럼을 특정 값으로 업데이트하려면 update_column_in_batches를 사용할 수 있습니다. 이 메서드는 업데이트를 배치로 나누어 하나의 구문에서 너무 많은 행을 업데이트하지 않도록 합니다.

다음은 projects 테이블에서 some_column이 'hello'인 행의 foo 칼럼을 10으로 업데이트합니다.

update_column_in_batches(:projects, :foo, 10) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

계산된 값으로 업데이트해야 한다면 값을 Arel.sql로 감싸 Arel이 SQL 리터럴로 취급하게 할 수 있습니다. 이는 Rails 6에서 요구하는 지원 중단 대응이기도 합니다.

아래 예시는 위 예시와 같지만 값을 bar 칼럼과 baz 칼럼의 곱으로 설정합니다.

update_value = Arel.sql('bar * baz')

update_column_in_batches(:projects, :foo, update_value) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

update_column_in_batches의 경우 테이블의 행 중 일부만 업데이트하는 것이라면 큰 테이블에서 실행해도 괜찮을 수 있습니다. 하지만 사전에 GitLab.com 스테이징 환경에서 검증하지 않고 (또는 다른 사람에게 검증을 요청하지 않고) 이를 넘겨짚지 않습니다.

외래 키 제약 조건 제거#

외래 키 제약 조건을 제거할 때는 외래 키와 관련된 두 테이블 모두에 대한 잠금을 획득해야 합니다. 쓰기가 많은 테이블에서는 with_lock_retries를 사용하는 것이 좋습니다. 그렇지 않으면 제때 잠금을 획득하지 못할 수 있습니다. 잠금을 획득할 때 교착 상태가 발생할 수도 있습니다. 애플리케이션은 보통 parent,child 순서로 쓰지만, 외래 키를 제거하면 child,parent 순서로 잠금을 획득하기 때문입니다. 이를 해결하려면 parent,child 순서로 잠금을 명시적으로 획득하면 됩니다. 예를 들면 다음과 같습니다.

disable_ddl_transaction!

def up
  with_lock_retries do
    execute('lock table ci_pipelines, ci_builds in access exclusive mode')

    remove_foreign_key :ci_builds, to_table: :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
  end
end

def down
  add_concurrent_foreign_key :ci_builds, :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
end

데이터베이스 테이블 삭제#

Note

테이블을 삭제한 후에는 데이터베이스 딕셔너리 가이드의 단계에 따라 데이터베이스 딕셔너리에 추가해야 합니다.

데이터베이스 테이블을 삭제하는 일은 드물며, Rails가 제공하는 drop_table 메서드는 일반적으로 안전한 것으로 간주됩니다. 테이블을 삭제하기 전에 다음을 고려합니다.

테이블에 트래픽이 많은 테이블(예: projects)을 가리키는 외래 키가 있으면 DROP TABLE 구문은 statement timeout 오류로 실패할 때까지 동시 트래픽을 지연시킬 가능성이 높습니다.

테이블에 레코드가 없고(기능이 사용된 적이 없음) 외래 키도 없는 경우:

  • 마이그레이션에서 drop_table 메서드를 사용합니다.
def change
  drop_table :my_table
end

테이블에 레코드가 있지만 외래 키는 없는 경우:

  • 모델, 컨트롤러, 서비스와 같이 테이블과 관련된 애플리케이션 코드를 제거합니다.
  • 배포 후 마이그레이션에서 drop_table을 사용합니다.

코드가 사용되지 않는다고 확신한다면 이 모두를 하나의 마이그레이션에 넣을 수 있습니다. 위험을 조금 더 줄이려면 애플리케이션 변경 사항이 머지된 후 마이그레이션을 두 번째 머지 리퀘스트에 넣는 것을 고려합니다. 이 방식은 롤백할 기회를 제공합니다.

def up
  drop_table :my_table
end

def down
  # create_table ...
end

테이블에 외래 키가 있는 경우:

  • 모델, 컨트롤러, 서비스와 같이 테이블과 관련된 애플리케이션 코드를 제거합니다.
  • 배포 후 마이그레이션에서 with_lock_retries 헬퍼 메서드를 사용해 외래 키를 제거합니다. 여러 외래 키를 제거한다면 잠금 경합을 피하기 위해 각 키를 별도의 마이그레이션에서 삭제해야 합니다.
  • 그 이후의 다른 배포 후 마이그레이션에서 drop_table을 사용합니다.

코드가 사용되지 않는다고 확신한다면 이 모두를 하나의 마이그레이션에 넣을 수 있습니다. 위험을 조금 더 줄이려면 애플리케이션 변경 사항이 머지된 후 마이그레이션을 두 번째 머지 리퀘스트에 넣는 것을 고려합니다. 이 방식은 롤백할 기회를 제공합니다.

비트랜잭션 마이그레이션으로 projects 테이블의 외래 키 제거:

# first migration file
class RemovingForeignKeyMigrationClass < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    with_lock_retries do
      remove_foreign_key :my_table, :projects
    end
  end

  def down
    add_concurrent_foreign_key :my_table, :projects, column: COLUMN_NAME
  end
end

테이블 삭제:

# second migration file
class DroppingTableMigrationClass < Gitlab::Database::Migration[2.1]
  def up
    drop_table :my_table
  end

  def down
    # create_table with the same schema but without the removed foreign key ...
  end
end

시퀀스 삭제#

시퀀스를 삭제하는 일은 드물지만, 데이터베이스 팀이 제공하는 drop_sequence 메서드를 사용할 수 있습니다.

내부적으로는 다음과 같이 동작합니다.

시퀀스 제거:

  • 시퀀스가 실제로 사용되고 있다면 기본값을 제거합니다.
  • DROP SEQUENCE를 실행합니다.

시퀀스 다시 추가:

  • 현재 값을 지정할 수 있는 방식으로 시퀀스를 생성합니다.
  • 칼럼의 기본값을 변경합니다.

Rails 마이그레이션 예시:

class DropSequenceTest < Gitlab::Database::Migration[2.1]
  def up
    drop_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq)
  end

  def down
    default_value = Ci::Pipeline.maximum(:id) + 10_000

    add_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq, default_value)
  end
end
Note

외래 키가 있는 칼럼에는 add_sequence를 사용하지 않아야 합니다. 이러한 칼럼에 시퀀스를 추가하는 것은 down 메서드(이전 스키마 상태 복원)에서만 허용됩니다.

테이블 비우기#

테이블을 비우는 일은 드물지만, 데이터베이스 팀이 제공하는 truncate_tables! 메서드를 사용할 수 있습니다.

내부적으로는 다음과 같이 동작합니다.

  • 비울 테이블의 gitlab_schema를 찾습니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있으면 TRUNCATE 구문을 실행합니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있지 않으면 아무 작업도 하지 않습니다.

기본 키 교체#

파티션 키가 기본 키에 포함되어야 하므로 테이블을 파티셔닝하려면 기본 키를 교체해야 합니다.

데이터베이스 팀이 제공하는 swap_primary_key 메서드를 사용할 수 있습니다.

내부적으로는 다음과 같이 동작합니다.

  • 기본 키 제약 조건을 삭제합니다.
  • 미리 정의해 둔 인덱스를 사용해 기본 키를 추가합니다.
class SwapPrimaryKey < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  TABLE_NAME = :table_name
  PRIMARY_KEY = :table_name_pkey
  OLD_INDEX_NAME = :old_index_name
  NEW_INDEX_NAME = :new_index_name

  def up
    swap_primary_key(TABLE_NAME, PRIMARY_KEY, NEW_INDEX_NAME)
  end

  def down
    add_concurrent_index(TABLE_NAME, :id, unique: true, name: OLD_INDEX_NAME)
    add_concurrent_index(TABLE_NAME, [:id, :partition_id], unique: true, name: NEW_INDEX_NAME)

    unswap_primary_key(TABLE_NAME, PRIMARY_KEY, OLD_INDEX_NAME)
  end
end
Note

기본 키를 교체하려면 별도의 마이그레이션에서 새 인덱스를 미리 만들어 두어야 합니다.

정수 칼럼 유형#

기본적으로 정수 칼럼은 최대 4바이트(32비트) 숫자를 담을 수 있습니다. 이는 최댓값 2,147,483,647에 해당합니다. 파일 크기를 바이트 단위로 담는 칼럼을 만들 때 이 점에 유의합니다. 파일 크기를 바이트로 추적한다면 최대 파일 크기가 2GB를 조금 넘는 수준으로 제한됩니다.

정수 칼럼이 최대 8바이트(64비트) 숫자를 담을 수 있게 하려면 제한을 8바이트로 명시적으로 설정합니다. 이렇게 하면 칼럼이 최대 9,223,372,036,854,775,807까지의 값을 담을 수 있습니다.

Rails 마이그레이션 예시:

add_column(:projects, :foo, :integer, default: 10, limit: 8)

문자열과 Text 데이터 유형#

자세한 내용은 text 데이터 유형 스타일 가이드를 참고합니다.

타임스탬프 칼럼 유형#

기본적으로 Rails는 시간대 정보 없이 타임스탬프 데이터를 저장하는 timestamp 데이터 유형을 사용합니다. timestamp 데이터 유형은 add_timestamps 또는 timestamps 메서드를 호출해 사용합니다.

또한 Rails는 :datetime 데이터 유형을 timestamp로 변환합니다.

예시:

# timestamps
create_table :users do |t|
  t.timestamps
end

# add_timestamps
def up
  add_timestamps :users
end

# :datetime
def up
  add_column :users, :last_sign_in, :datetime
end

이러한 메서드 대신 다음 메서드를 사용해 시간대가 포함된 타임스탬프를 저장해야 합니다.

  • add_timestamps_with_timezone
  • timestamps_with_timezone
  • datetime_with_timezone

이렇게 하면 모든 타임스탬프에 시간대가 지정됩니다. 그 결과 시스템의 시간대가 바뀌어도 기존 타임스탬프가 갑자기 다른 시간대를 사용하지 않게 됩니다. 또한 처음에 어떤 시간대가 사용되었는지도 분명하게 알 수 있습니다.

데이터베이스에 JSON 저장#

Rails 5는 JSONB(바이너리 JSON) 칼럼 유형을 기본으로 지원합니다. 이 칼럼을 추가하는 마이그레이션 예시:

class AddOptionsToBuildMetadata < Gitlab::Database::Migration[2.1]
  def change
    add_column :ci_builds_metadata, :config_options, :jsonb
  end
end

기본적으로 해시 키는 문자열입니다. 선택적으로 사용자 지정 데이터 유형을 추가해 키에 다른 방식으로 접근하게 할 수 있습니다.

class BuildMetadata
  attribute :config_options, ::Gitlab::Database::Type::IndifferentJsonb.new # for indifferent access or ::Gitlab::Database::Type::SymbolizedJsonb.new if you need symbols only as keys.
end

JSONB 칼럼을 사용할 때는 JsonSchemaValidator를 사용해 시간이 지나도 삽입되는 데이터를 통제해야 합니다. 큰 JSONB 데이터로 인한 성능 문제를 방지하기 위해 size_limit도 지정해야 하며, 권장 최댓값은 64KB입니다.

JSON 스키마에서 additionalProperties: false를 사용한다면 속성을 추가하거나 제거할 때의 배포 요구 사항은 스키마 검증이 있는 JSON/JSONB 칼럼 변경 을 참고합니다.

제한 없는 JSONB 증가는 수백만 개의 데이터베이스 레코드에서 메모리 압박과 쿼리 성능 저하를 일으킬 수 있으므로, JsonbSizeLimit cop이 새 검증에 이 요구 사항을 강제합니다. 더 큰 데이터 세트에는 오브젝트 스토리지를 사용하고 데이터베이스에는 참조만 저장합니다.

class BuildMetadata
  validates :config_options, json_schema: { filename: 'build_metadata_config_option', size_limit: 64.kilobytes }
end

또한 JSONB 칼럼의 키를 ActiveRecord 속성으로 노출할 수 있습니다. 복잡한 검증이나 ActiveRecord 변경 추적이 필요할 때 사용합니다. 이 기능은 jsonb_accessor gem이 제공하며 JsonSchemaValidator를 대체하지 않습니다.

module Organizations
  class OrganizationSetting < ApplicationRecord
    belongs_to :organization

    validates :settings, json_schema: { filename: "organization_settings" }

    jsonb_accessor :settings,
      restricted_visibility_levels: [:integer, { array: true }]

    validates_each :restricted_visibility_levels do |record, attr, value|
      value&.each do |level|
        unless Gitlab::VisibilityLevel.options.value?(level)
          record.errors.add(attr, format(_("'%{level}' is not a valid visibility level"), level: level))
        end
      end
    end
  end
end

이제 restricted_visibility_levels를 ActiveRecord 속성으로 사용할 수 있습니다.

> s = Organizations::OrganizationSetting.find(1)
=> #
> s.settings
=> {"restricted_visibility_levels"=>[20]}
> s.restricted_visibility_levels
=> [20]
> s.restricted_visibility_levels = [0]
=> [0]
> s.changes
=> {"settings"=>[{"restricted_visibility_levels"=>[20]}, {"restricted_visibility_levels"=>[0]}], "restricted_visibility_levels"=>[[20], [0]]}

암호화된 속성#

encrypts 속성을 데이터베이스에 :text로 저장하지 않고 :jsonb를 대신 사용합니다. 이렇게 하면 PostgreSQL의 JSONB 유형을 사용하며 저장 공간을 더 효율적으로 쓸 수 있습니다.

class AddSecretToSomething < Gitlab::Database::Migration[2.1]
  def change
    add_column :something, :secret, :jsonb, null: true
  end
end

암호화된 속성을 JSONB 칼럼에 저장할 때는 Active Record Encryption 권장 사항을 따르는 길이 검증을 추가하는 것이 좋습니다. 대부분의 암호화된 속성에는 최대 길이 510이면 충분합니다.

class Something < ApplicationRecord
  encrypts :secret
  validates :secret, length: { maximum: 510 }
end

타입 안전성을 갖춘 강화된 검증#

데이터 무결성을 더 높이려면 암호화 전에 평문 값의 형식과 유형을 모두 검증합니다.

class Something < ApplicationRecord
  encrypts :secret

  validates :secret,
            length: { maximum: 510 },
            format: { with: /\A[a-zA-Z]+\z/, allow_nil: true }

  validate :ensure_string_type

  private

  def ensure_string_type
    unless secret.is_a?(String) || secret.nil?
      errors.add(:secret, "must be a string")
    end
  end
end

이 접근 방식은 Rails의 형식 검증기를 사용해 (기반이 되는 JSONB 값이 아니라) 평문 값을 검증하고, 속성이 String 또는 nil임을 단언해 타입 안전성을 보장합니다.

테스트#

Rails 마이그레이션 테스트 스타일 가이드를 참고합니다.

데이터 마이그레이션#

일반적인 ActiveRecord 구문보다 Arel과 일반 SQL을 우선 사용합니다. 일반 SQL을 사용하는 경우 모든 입력을 quote_string 헬퍼로 직접 인용해야 합니다.

Arel 예시:

users = Arel::Table.new(:users)
users.group(users[:user_id]).having(users[:id].count.gt(5))

#update other tables with these results

일반 SQL과 quote_string 헬퍼 예시:

select_all("SELECT name, COUNT(id) as cnt FROM tags GROUP BY name HAVING COUNT(id) > 1").each do |tag|
  tag_name = quote_string(tag["name"])
  duplicate_ids = select_all("SELECT id FROM tags WHERE name = '#{tag_name}'").map{|tag| tag["id"]}
  origin_tag_id = duplicate_ids.first
  duplicate_ids.delete origin_tag_id

  execute("UPDATE taggings SET tag_id = #{origin_tag_id} WHERE tag_id IN(#{duplicate_ids.join(",")})")
  execute("DELETE FROM tags WHERE id IN(#{duplicate_ids.join(",")})")
end

더 복잡한 로직이 필요하다면 마이그레이션에서만 쓰는 모델을 정의해 사용할 수 있습니다. 예를 들면 다음과 같습니다.

class MyMigration < Gitlab::Database::Migration[2.1]
  class Project < MigrationRecord
    self.table_name = 'projects'
  end

  def up
    # Reset the column information of all the models that update the database
    # to ensure the Active Record's knowledge of the table structure is current
    Project.reset_column_information

    # ... ...
  end
end

이렇게 할 때는 모델의 테이블 이름이 클래스 이름이나 네임스페이스에서 유추되지 않도록 명시적으로 설정해야 합니다.

마이그레이션에서 모델을 사용할 때의 한계에 유의합니다.

기존 데이터 수정#

대부분의 경우 데이터베이스의 데이터를 수정할 때는 배치로 마이그레이션하는 것이 좋습니다.

컬렉션을 성능 좋게 순회하는 과정을 돕는 헬퍼 each_batch_range를 사용합니다. 기본 배치 크기는 BATCH_SIZE 상수에 정의되어 있습니다.

다음 예시를 통해 사용법을 파악할 수 있습니다.

배치로 데이터 삭제:

disable_ddl_transaction!

def up
  each_batch_range('ci_pending_builds', scope: ->(table) { table.ref_protected }, of: BATCH_SIZE) do |min, max|
    execute <<~SQL
      DELETE FROM ci_pending_builds
        USING ci_builds
        WHERE ci_builds.id = ci_pending_builds.build_id
          AND ci_builds.status != 'pending'
          AND ci_builds.type = 'Ci::Build'
          AND ci_pending_builds.id BETWEEN #{min} AND #{max}
    SQL
  end
end
  • 첫 번째 인수는 수정할 테이블입니다. 'ci_pending_builds'
  • 두 번째 인수는 선택한 관련 데이터 세트를 가져오는 람다를 호출합니다(기본값은 .all). scope: ->(table) { table.ref_protected }
  • 세 번째 인수는 배치 크기입니다(기본값은 BATCH_SIZE 상수에 설정되어 있음). of: BATCH_SIZE

이 헬퍼의 사용법을 보여 주는 예시 머지 리퀘스트이 있습니다.

마이그레이션에서 애플리케이션 코드 사용 (권장하지 않음)#

마이그레이션에서 애플리케이션 코드(모델 포함)를 사용하는 것은 일반적으로 권장하지 않습니다. 마이그레이션은 오랫동안 남아 있고 마이그레이션이 의존하는 애플리케이션 코드는 이후에 바뀌어 마이그레이션이 깨질 수 있기 때문입니다. 과거에는 일부 백그라운드 마이그레이션이 여러 파일에 흩어진 수백 줄의 코드를 마이그레이션에 복사하는 것을 피하기 위해 애플리케이션 코드를 사용해야 했습니다. 이처럼 드문 경우에는 마이그레이션에 충분한 테스트를 갖춰, 나중에 코드를 리팩터링하는 사람이 마이그레이션을 깨뜨렸을 때 이를 알 수 있게 하는 것이 중요합니다. 애플리케이션 코드 사용은 배치 백그라운드 마이그레이션에서도 권장하지 않으며, 모델은 마이그레이션 안에서 선언해야 합니다.

보통 MigrationRecord를 상속하는 클래스를 정의하면 마이그레이션에서 애플리케이션 코드(특히 모델)를 사용하지 않을 수 있습니다(아래 예시 참고).

모델(마이그레이션에서 정의한 모델 포함)을 사용한다면 먼저 reset_column_information을 사용해 칼럼 캐시를 지워야 합니다.

단일 테이블 상속(STI)을 활용하는 모델을 사용한다면 특별히 고려할 사항이 있습니다.

이렇게 하면 사용 중인 칼럼이 이전 마이그레이션에서 변경되어 캐시된 경우에 생기는 문제를 피할 수 있습니다.

예시: users 테이블에 my_column 칼럼 추가#

이전 스키마가 캐시에서 삭제되고 ActiveRecord가 업데이트된 스키마 정보를 불러오도록 User.reset_column_information 명령을 빠뜨리지 않는 것이 중요합니다.

class AddAndSeedMyColumn < Gitlab::Database::Migration[2.1]
  class User < MigrationRecord
    self.table_name = 'users'
  end

  def up
    User.count # Any ActiveRecord calls on the model that caches the column information.

    add_column :users, :my_column, :integer, default: 1

    User.reset_column_information # The old schema is dropped from the cache.
    User.find_each do |user|
      user.my_column = 42 if some_condition # ActiveRecord sees the correct schema here.
      user.save!
    end
  end
end

기본 테이블을 수정한 다음 ActiveRecord를 사용해 접근합니다.

같은 db:migrate 프로세스에서 두 마이그레이션이 실행된다면, 이전의 다른 마이그레이션에서 테이블을 수정한 경우에도 이 방법을 사용해야 합니다.

그 결과는 다음과 같습니다. my_column이 포함된 점에 유의합니다.

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-18T00:41:26.851769 #459802] DEBUG -- :   AddAndSeedMyColumn::User Update (1.1ms)  UPDATE "users" SET "my_column" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["my_column", 42], ["updated_at", "2020-07-17 23:41:26.849044"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

스키마 캐시를 지우지 않으면(User.reset_column_information) 해당 칼럼을 ActiveRecord가 사용하지 않아 의도한 변경이 이루어지지 않으며, 그 결과는 아래와 같습니다. 쿼리에서 my_column이 빠져 있습니다.

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-06T00:37:12.653459 #130101] DEBUG -- :   AddAndSeedMyColumn::User Update (0.5ms)  UPDATE "users" SET "updated_at" = $1 WHERE "users"."id" = $2  [["updated_at", "2020-07-05 23:37:12.652297"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

트래픽이 많은 테이블#

현재 트래픽이 많은 테이블 목록은 다음과 같습니다.

어떤 테이블이 트래픽이 많은 테이블인지 판단하기는 어려울 수 있습니다. GitLab Self-Managed 인스턴스는 사용 패턴이 다른 GitLab의 여러 기능을 사용할 수 있으므로 GitLab.com을 기준으로 한 가정만으로는 충분하지 않습니다.

GitLab.com에서 트래픽이 많은 테이블을 식별하기 위해 다음 지표를 고려합니다. 여기에 링크된 메트릭은 GitLab 내부용입니다.

읽기 작업이 현재 트래픽이 많은 테이블에 견줄 만큼 많은 테이블은 후보가 될 수 있습니다.

일반적으로 GitLab.com의 분석이나 보고만을 위한 칼럼을 트래픽이 많은 테이블에 추가하는 것은 권장하지 않습니다. 이러한 칼럼은 모든 GitLab Self-Managed 인스턴스에 직접적인 기능 가치를 제공하지 못하면서 성능에 부정적인 영향을 줄 수 있습니다.

트리거 생성#

트래픽이 많은 테이블에 트리거를 생성하면 배포 중 잠금 경합 타임아웃이 발생할 수 있습니다. 이를 완화하려면 with_lock_retries 헬퍼 메서드를 사용해 배포 후 마이그레이션에서 트리거를 생성할 수 있습니다. 또한 마이그레이션이 중간에 실패했을 때 재시도할 수 있고 함수나 트리거가 이미 존재해도 실패하지 않도록 마이그레이션을 멱등하게 만들어야 합니다.

class AddTriggersToHighTrafficTable < Gitlab::Database::Migration[2.3]
  milestone '18.10'

  disable_ddl_transaction!

  TRIGGER_FUNCTION_NAME = 'function_name_here'
  TRIGGER_NAME = 'trigger_name_here'
  TABLE_NAME = :table_name

  def up
    with_lock_retries do
      create_trigger_function(TRIGGER_FUNCTION_NAME, replace: true) do
        # function body
      end

      create_trigger(TABLE_NAME, TRIGGER_NAME, TRIGGER_FUNCTION_NAME, fires: 'AFTER INSERT', replace: true)
    end
  end

  def down
    with_lock_retries do
      drop_trigger(TABLE_NAME, TRIGGER_NAME, if_exists: true)
    end

    drop_function(TRIGGER_FUNCTION_NAME, if_exists: true)
  end
end

with_lock_retries를 사용하려면 disable_ddl_transaction!이 필요합니다. create_trigger 헬퍼로 트리거를 생성할 수 없는 경우 (예: 트리거가 행마다가 아니라 구문마다 실행되는 경우)에는 트리거를 생성할 때 CREATE OR REPLACE TRIGGER를 사용합니다.

마일스톤#

모든 새 마이그레이션은 다음 구문으로 마일스톤을 지정해야 합니다.

class AddFooToBar < Gitlab::Database::Migration[2.2]
  milestone '16.6'

  def change
    # Your migration here
  end
end

마이그레이션에 올바른 마일스톤을 추가하면 마이그레이션을 해당하는 GitLab 마이너 버전별로 논리적으로 나눌 수 있습니다. 그 효과는 다음과 같습니다.

  • 업그레이드 프로세스가 단순해집니다.
  • 마이그레이션의 타임스탬프에만 의존해 순서를 정할 때 생길 수 있는 마이그레이션 순서 문제를 완화합니다.

Autovacuum 랩어라운드 방지#

이는 PostgreSQL의 특수한 autovacuum 실행 모드이며, 정리(vacuum) 대상 테이블에 대한 ShareUpdateExclusiveLock이 필요합니다. 더 큰 테이블에서는 몇 시간이 걸릴 수 있으며, 이 잠금은 같은 시간에 테이블을 수정하려는 대부분의 DDL 마이그레이션과 충돌할 수 있습니다. 마이그레이션이 제때 잠금을 획득하지 못하면 실패하고 배포를 차단합니다.

배포 후 마이그레이션(PDM) 파이프라인은 테이블 중 하나에서 랩어라운드 방지 vacuum 프로세스를 감지하면 실행을 확인하고 중단할 수 있습니다. 이렇게 하려면 마이그레이션 이름에 전체 테이블 이름을 사용해야 합니다. 예를 들어 add_foreign_key_between_ci_builds_and_ci_job_artifacts는 마이그레이션을 실행하기 전에 ci_builds와 ci_job_artifacts의 vacuum 여부를 확인합니다.

마이그레이션에 충돌하는 잠금이 없다면 전체 테이블 이름을 사용하지 않아 vacuum 확인을 건너뛸 수 있습니다. 예: create_async_index_on_job_artifacts