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

GitLab에 마이그레이션을 작성할 때, 여러 규모의 조직에서 실행되는 것을 고려해야 합니다. 몇 년간의 데이터를 가진 수백 개의 조직이 있을 수 있습니다.

또한, 업그레이드를 위해 서버를 오프라인 상태로 전환하는 것은 대부분의 조직에게 큰 부담입니다. 이유로 마이그레이션이 신중하게 작성되어 온라인으로 적용되며 아래 스타일 가이드에 따르는 것이 중요합니다.

마이그레이션은 절대로 GitLab 설치가 오프라인 상태여서는 안됩니다. 마이그레이션은 항상 다운타임을 피하기 위해 주의 깊게 작성되어야 합니다. 과거에는 DOWNTIME 상수를 설정하여 다운타임을 허용하는 마이그레이션을 정의하는 프로세스가 있었습니다. 이전 마이그레이션을 살펴보면 이를 확인할 수 있습니다. 그러나 이 프로세스는 4년 동안 사용된 적이 없고, 마이그레이션을 다른 방식으로 작성하여 다운타임을 피할 수 있다는 점을 깨달았습니다.

마이그레이션을 작성할 때 데이터베이스에는 시대에 뒤떨어진 데이터나 불일치가 있을 수 있으므로 이를 주의해야 합니다. 데이터베이스 상태에 가능한 가정을 최소화하려고 노력하세요.

향후 버전에서 변경될 수 있는 GitLab 특정 코드에 의존하지 마세요. 필요한 경우 마이그레이션에 GitLab 코드를 복사하여 앞으로 호환되도록 만드세요.

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

새로운 마이그레이션을 추가하기 전에 가장 적합한 유형을 결정해야 합니다.

현재 만들 수 있는 세 가지 종류의 마이그레이션이 있으며, 수행할 작업의 유형 및 완료까지 소요되는 시간에 따라 다릅니다:

  1. 정규 스키마 마이그레이션. 이것은 db/migrate의 전통적인 Rails 마이그레이션으로, 새로운 애플리케이션 코드가 배포되기 에 실행됩니다. (GitLab.com의 Canary가 배포되기 전에). 그러므로 배포를 불필요하게 지연시키지 않도록 여유 시간을 가지고, 몇 분 이상 소요되지 않아야 합니다.

    다만, 애플리케이션이 올바르게 작동하기 위해 절대적으로 중요한 마이그레이션의 한 예로, 고유한 튜플을 강제하는 인덱스나 애플리케이션의 중요한 부분에서 쿼리 성능을 위해 필요한 인덱스가 있을 수 있습니다. 그러나 마이그레이션이 받아들일 수 없을 정도로 느리다면 기능 플래그로 기능을 보호하고, 배포 후 마이그레이션을 수행하는 것이 더 나은 옵션이 될 수 있습니다. 마이그레이션이 완료된 후 해당 기능을 켤 수 있습니다.

    새 모델을 추가하는 마이그레이션도 이러한 정규 스키마 마이그레이션의 일환입니다. 차이점은 마이그레이션 생성에 사용된 Rails 명령 및 추가 생성된 파일, 모델 및 모델의 spec 파일 중 하나입니다.

  2. 배포 후 마이그레이션. 이것은 db/post_migrate의 Rails 마이그레이션으로, GitLab.com 배포에서 독립적으로 실행됩니다. 보류 중인 후 마이그레이션은 릴리스 관리자의 재량으로 배포 후 마이그레이션 파이프라인을 통해 매일 실행됩니다. 이들 마이그레이션은 애플리케이션이 작동하는 데 필수적이지 않은 스키마 변경 또는 몇 분 안에 완료되는 데이터 마이그레이션에 사용될 수 있습니다.

    다음은 반드시 정규 스키마 마이그레이션으로 수행되어야 하는 변화로, 배포 후 마이그레이션에서 실시하는 변경 사항은 다음과 같습니다:

    • 사용되지 않는 열 제거와 같은 정리 작업.
    • 고트래픽 테이블에 중요하지 않은 인덱스 추가.
    • 오랜 시간이 걸리는 중요하지 않은 인덱스 추가.

    애플리케이션이 작동에 필수적인 스키마 변경은 배포 후 마이그레이션에서 수행하지 말아야 합니다. 이러한 스키마 변경은 과거에 문제를 일으킨 바 있으며, 예를 들어 이 문제가 있습니다. 정규 스키마 마이그레이션으로 반드시 수행되어야 하는 변경으로, 다음과 같은 사항이 있습니다:

    • 새로운 테이블 생성, 예: create_table.
    • 기존 테이블에 새 열 추가, 예: add_column.
  3. 일괄 배경 마이그레이션. 이것은 일반적인 Rails 마이그레이션이 아니며, Sidekiq 작업을 통해 실행되는 애플리케이션 코드입니다. 그러나 배포 후 마이그레이션이 이를 예약하는 데 사용됩니다. 이들은 배포 후 마이그레이션의 시간 가이드라인을 초과하는 데이터 마이그레이션에만 사용해야 합니다. 일괄 배경 마이그레이션을 사용할 때 스키마를 변경해서는 안됩니다.

결정을 안내하는 데 다음 다이어그램을 사용하되, 항상 특정한 변경 사항에 의존하므로 마지막 결과는 항상 특정 변경 사항에 따라 달라질 수 있음을 기억하세요.

graph LR A{스키마<br/>변경됨?} A -->|Yes| C{속도 또는<br/>동작에 중요함?} A -->|No| D{빠른가?} C -->|Yes| H{빠른가?} C -->|No| F[배포 후 마이그레이션] H -->|Yes| E[정규 마이그레이션] H -->|No| I[배포 후 마이그레이션<br/>+ 기능 플래그] D -->|Yes| F[배포 후 마이그레이션] D -->|No| G[백그라운드 마이그레이션]

마이그레이션이 얼마나 걸릴까요

일반적으로 단일 배포에 대한 모든 마이그레이션은 GitLab.com에서 1시간을 넘지 않아야 합니다. 다음 지침들은 엄격한 규칙이 아니며, 최소한의 마이그레이션 기간 유지를 위해 추정되었습니다.

주의: 모든 지속 기간은 GitLab.com에 대해 측정되어야 합니다.

마이그레이션 유형 권장 지속 기간 참고
일반적인 마이그레이션 <= 3분 응용 프로그램 기능 또는 성능이 심각하게 저하되고 지연할 수 없는 변경 사항이 유효한 예외입니다.
배포 후 마이그레이션 <= 10분 스키마 변경은 이후의 배경 마이그레이션에서 일어나지 않아야 합니다.
배경 마이그레이션 > 10분 이는 더 큰 테이블에 적합하기 때문에 정확한 시간 지침을 설정할 수 없지만, 단일 쿼리는 1초 실행 시간 미만으로 유지되어야 합니다. 캐시가 차가운 상태에서.

대상 데이터베이스 선택

GitLab은 두 가지 다른 Postgres 데이터베이스에 연결됩니다: ‘main’과 ‘ci’. 이 분리는 마이그레이션에 영향을 줄 수 있습니다 마이그레이션이 이러한 데이터베이스 중 하나 또는 모두에서 실행될 수 있기 때문입니다.

여러 데이터베이스용 마이그레이션을 읽어서 추가할 마이그레이션이 이를 어떻게 고려해야 하는지 이해하세요.

정기 스키마 마이그레이션 만들기

마이그레이션을 만들려면 다음과 같은 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에 spec 파일

스키마 변경

스키마 변경은 db/structure.sql에 커밋되어야 합니다. 이 파일은 typicallRail로 실행할 때 자동으로 생성되며, bundle exec rails db:migrate를 실행할 때 자동으로 생성되는 파일이므로 일반적으로 이 파일을 직접 편집해서는 안 됩니다. 만약 당신의 마이그레이션이 테이블에 컬럼을 추가하고 있다면, 그 컬럼은 맨 아래에 추가됩니다. 기존 테이블에 대해 컬럼을 수동으로 재정렬하지 마십시오. 이로 인해 Rails가 생성한 db/structure.sql을 사용하는 다른 사람들에게 혼란을 야기시킵니다.

주의: 비동기식으로 색인을 생성하려면 두 개의 병합 요청이 필요합니다. 완료하면 병합 요청에 스키마 변경사항을 커밋하십시오 add_concurrent_index를 사용합니다.

로컬 데이터베이스가 main의 스키마와 다르다면 Git에 스키마 변경사항을 깔끔하게 커밋하는 것이 어려울 수 있습니다. 이 경우, scripts/regenerate-schema 스크립트를 사용하여 추가하려는 마이그레이션에 대한 깨끗한 db/structure.sql을 재생성할 수 있습니다. 이 스크립트는 db/migratedb/post_migrate에서 발견된 모든 마이그레이션을 적용합니다. 그러므로 스키마에 커밋하고 싶지 않은 마이그레이션이 있는 경우에는 이름을 바꾸거나 제거하십시오. 만약 당신의 브랜치가 기본 Git 브랜치를 대상으로 하지 않는다면 TARGET 환경 변수를 설정할 수 있습니다.

# `main` 대상으로 스키마 재생성
scripts/regenerate-schema

# `12-9-stable-ee` 대상으로 스키마 재생성
TARGET=12-9-stable-ee scripts/regenerate-schema

scripts/regenerate-schema 스크립트는 추가적인 차이를 만들어낼 수 있습니다. 이런 경우, 수작업 절차를 사용하십시오. 여기서 <migration ID>는 마이그레이션 파일의 DATETIME 부분입니다.

# 마스터에 대한 재베이스
git rebase master

# 변경사항 롤백
VERSION=<migration ID> bundle exec rails db:rollback:main

# 마스터에서 db/structure.sql 체크아웃
git checkout origin/master db/structure.sql

# 변경사항 마이그레이션
VERSION=<migration ID> bundle exec rails db:migrate:main

테이블을 생성한 후, 데이터베이스 사전에 추가해야 합니다.

다운타임 피하기

“마이그레이션에서 다운타임 피하기” 문서에서 각종 데이터베이스 작업, 예를 들어:

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

되돌릴 수 있는지 확인하기

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

주의: GitLab 프로덕션 환경에서 문제가 발생하면 db:rollback을 사용하여 마이그레이션을 되돌리는 대신 롤 포워드 전략이 사용됩니다. 셀프매니지드 인스턴스에서는 업그레이드 프로세스를 시작하기 전에 생성된 백업을 복원하는 것을 권장합니다. down 메소드는 주로 개발 환경에서 사용되며, 예를 들어 개발자가 커밋이나 브랜치 간에 스위칭할 때 로컬 structure.sql 파일과 데이터베이스가 일관된 상태인지 확인하고자 할 때 사용합니다.

당신의 마이그레이션에는 마이그레이션이 되돌릴 수 있는 여부를 테스트한 설명이 포함되어야 합니다.

일부 마이그레이션은 되돌릴 수 없을 수 있습니다. 예를 들어, 일부 데이터 마이그레이션은 데이터베이스 상태에 대한 정보를 잃기 때문에 되돌릴 수 없을 수 있습니다. 그럼에도 불구하고 마이그레이션 자체는 되돌릴 수 있어야 하므로 up 메소드에서 수행된 변경 사항을 되돌릴 수 없는 이유를 설명하는 주석과 함께 down 메소드를 만들어야 합니다.

def down
  # 아무것도 수행하지 않음

  # `up` 메소드에서 수행된 변경 사항을 되돌릴 수 없는 이유를 설명하는 주석을 달아주세요.
end

이러한 종류의 마이그레이션은 본질적으로 위험하며, 데이터 마이그레이션을 추가할 때 추가 작업이 필요합니다.

원자성 및 트랜잭션

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

단일 트랜잭션에서 마이그레이션을 실행하면 하나의 단계가 실패하더라도 다른 단계는 실행되지 않으므로 데이터베이스가 유효한 상태로 유지됩니다. 따라서 다음 중 하나를 수행해야 합니다:

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

예를 들어, 빈 테이블을 만들고 그에 대한 인덱스를 작성해야 하는 경우, 보통의 단일 트랜잭션 마이그레이션과 기본 rails 스키마 문(statement)인 add_index을 사용해야 합니다. 이 작업은 블로킹 작업이지만, 테이블이 아직 사용되지 않으므로 레코드가 아직 없기 때문에 문제가 되지 않습니다.

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

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

단일 트랜잭션 마이그레이션을 사용할 때, 트랜잭션은 마이그레이션의 기간 동안 데이터베이스 연결을 유지하므로 마이그레이션에서 실행되는 작업이 너무 많은 시간을 소비하지 않도록 주의해야 합니다. 일반적으로 트랜잭션은 빨리 실행되어야 합니다. 이를 위해 마이그레이션에서 실행되는 각 쿼리에 대한 최대 쿼리 시간 제한을 확인하세요.

단일 트랜잭션 마이그레이션이 완료되기까지 시간이 오래 걸린다면, 여러 가지 옵션이 있습니다. 모든 경우에서 마이그레이션이 얼마나 오래 걸리는지에 따라 적절한 마이그레이션 유형을 선택해야 합니다.

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

  • disable_ddl_transaction!을 사용하여 **여러 트랜잭션을 사용합니다.
  • 설정된 문(statement) 및 잠금 시간 초과 설정을 조정한 후에도 계속하여 단일 트랜잭션 마이그레이션을 사용합니다. 무거운 작업이 트랜잭션의 보장을 사용해야 하는 경우에는 마이그레이션이 시간 초과 제한에 도달하지 않는지 확인해야 합니다. 같은 조언은 단일 트랜잭션 마이그레이션과 각각의 트랜잭션에도 적용됩니다.

    • Statement timeout: 문(statement) 시간 초과는 GitLab.com의 프로덕션 데이터베이스에 대해 15초로 구성되어 있지만, 인덱스를 생성하는 데는 15초보다 많은 시간이 걸릴 수 있습니다. 기존의 도우미를 사용할 때 add_concurrent_index를 포함하여 필요에 따라 문(statement) 시간 초과를 자동으로 해제합니다. 드물게는 부인 문(disable_statement_timeout)을 사용하여 시간 제한을 설정해야 할 수도 있습니다.

참고: 마이그레이션을 실행하기 위해서는 statement_timeoutlock_wait_timeout과 같은 설정을 제어하기 위해 PgBouncer를 우회하여 주(primary) 데이터베이스에 직접 연결합니다.

임시로 문(statement) 시간 초과 제한 해제

마이그레이션 도우미 disable_statement_timeout은 한 번에 트랜잭션 또는 연결당 문(statement) 시간 초과를 0으로 설정할 수 있도록 합니다.

  • 트랜잭션 내에서 실행되지 않는 문(statement)의 경우 연결 당 옵션을 사용합니다. 예를 들어 CREATE INDEX CONCURRENTLY와 같이 명시적 트랜잭션 내에서 실행되지 않는 문(statement)의 경우 이 옵션을 사용해야 합니다.

  • 명시적 트랜잭션 블록 내에서 실행되는 문(statement)을 지원하는 경우, 예를 들어 ALTER TABLE ... VALIDATE CONSTRAINT와 같이 명시적 트랜잭션 블록 내에서 실행되는 문(statement) 경우 트랜잭션 당 옵션을 사용해야 합니다.

disable_statement_timeout을 사용하는 것은 거의 필요하지 않을 수 있으며, 대부분의 마이그레이션 도우미가 필요에 따라 이미 내부적으로 사용합니다. 예를 들어, 인덱스를 생성하는 일반적으로 15초보다 많은 시간이 걸릴 수 있으므로 GitLab.com의 프로덕션 데이터베이스에 대한 기본 문(statement) 시간 초과로 자동으로 해제하지 않고, add_concurrent_index 도우미가 연결 당 문(statement) 시간 초과를 해제하기 위해 전달된 블록 내에서 인덱스를 생성합니다.

마이그레이션에서 원시 SQL 문(statement)을 작성하는 경우, 수동으로 disable_statement_timeout를 사용해야 할 수 있습니다. 이 경우에는 데이터베이스 검토 및 유지 관리자에게 상담해야 합니다.

트랜잭션으로 래핑된 마이그레이션 비활성화

disable_ddl_transaction!을 사용하여 마이그레이션을 단일 트랜잭션으로 실행하지 않도록 선택할 수 있습니다. 이는 ActiveRecord 메서드입니다. 이 메서드는 다른 데이터베이스 시스템에서 다른 결과로 호출될 수 있습니다. GitLab에서는 PostgreSQL만을 전용으로 사용합니다. disable_ddl_transaction!을 항상 다음과 같이 읽으시면 됩니다:

“이 마이그레이션을 단일 PostgreSQL 트랜잭션으로 실행하지 마십시오. 필요할 때와 필요할 때에만 PostgreSQL 트랜잭션을 엽니다.”

참고: 명시적 PostgreSQL 트랜잭션(.transaction 또는 BEGIN; COMMIT;)을 사용하지 않더라도 모든 SQL 문(statement)은 여전히 트랜잭션으로 실행됩니다. 트랜잭션에 대해 PostgreSQL 문서를 확인하세요(https://www.postgresql.org/docs/current/tutorial-transactions.html).

참고: GitLab에서 때때로 disable_ddl_transaction!을 사용하는 마이그레이션을 트랜잭션을 사용하지 않는 마이그레이션으로 언급하기도 합니다. 이는 마이그레이션이 단일 트랜잭션으로 실행되지 않았음을 의미합니다.

언제 disable_ddl_transaction!을 사용해아 하는지는 대부분의 경우 기존의 RuboCop 규칙 또는 마이그레이션 도우미가 이를 감지할 수 있습니다. 수정할 것이 있는지 확실하지 않은 경우, RuboCop 규칙 및 데이터베이스 리뷰의 지침을 따르도록 하세요.

disable_ddl_transaction!을 사용해야 하는 경우는 다음과 같습니다: - 명시적 트랜잭션 외부에서 PostgreSQL에서 작업을 실행해야 하는 경우입니다.

  • 이에 대한 가장 대표적인 예시는 CREATE INDEX CONCURRENTLY 명령입니다. PostgreSQL은 블로킹 버전(CREATE INDEX)을 트랜잭션 내에서 실행할 수 있지만, CREATE INDEX CONCURRENTLY는 반드시 트랜잭션 외부에서 수행되어야 합니다. 따라서, 마이그레이션이 단 한 문장인 CREATE INDEX CONCURRENTLY를 실행하더라도 disable_ddl_transaction!을 비활성화해야 합니다. 이것은 add_concurrent_index 도우미의 사용이 disable_ddl_transaction!을 요구하는 이유입니다. CREATE INDEX CONCURRENTLY는 예외 사항일 뿐입니다.

  • 어떤 이유로든 마이그레이션에서 여러 번의 트랜잭션을 실행해야 하는 경우에도 disable_ddl_transaction!을 사용합니다. 대부분의 경우, 대량의 데이터를 삽입, 업데이트 또는 삭제(DML)해야 하는 경우 배치(batch)로 수행해야 합니다. 각 배치를 처리할 때 연산을 그룹화해야 하는 경우, 배치 작업을 처리하는 동안 명시적으로 트랜잭션 블록을 열 수 있습니다. 어느 정도 큰 작업을 위한 배치된 백그라운드 마이그레이션을 사용하는 것을 고려해보세요.

disable_ddl_transaction!을 사용해야 하는 경우는 다음과 같습니다: 다양한 마이그레이션 도우미가 정밀한 통제가 필요하기 때문에 disable_ddl_transaction!로 실행되어야 합니다.

  • 외래 키는 트랜잭션 내에서 추가할 수 있습니다. CREATE INDEX CONCURRENTLY와 달리 PostgreSQL은 외래 키를 실행할 수 있습니다. 하지만, PostgreSQL은 CREATE INDEX CONCURRENTLY와 유사한 옵션을 제공하지 않습니다. 대신에 add_concurrent_foreign_key 도우미는 외래 키를 추가 및 유효성을 검사하는 동안 소스 및 대상 테이블을 잠그기 위해 자체 트랜잭션을 엽니다.
  • 앞서 설명한 바와 같이, 확실히 하지 못할 경우 disable_ddl_transaction!을 건너뛰고 RuboCop 규칙이 위반되었는지 확인하세요.

disable_ddl_transaction!을 사용해야 하는 경우는 다음과 같습니다: 마이그레이션에서 실제로 PostgreSQL 데이터베이스에 접근하지 않거나 여러 개의 PostgreSQL 데이터베이스에 접근하는 경우입니다.

  • 예를 들어, 마이그레이션이 Redis 서버를 대상으로 할 수 있습니다. 보통은 PostgreSQL 트랜잭션 내에서 외부 서비스와 상호 작용할 수 없습니다.
  • 트랜잭션은 단일 데이터베이스 연결에 사용됩니다. 마이그레이션이 여러 데이터베이스를 대상으로 하는 경우, 다중 데이터베이스용 마이그레이션을 따르세요.

네이밍 규칙

데이터베이스 객체(테이블, 인덱스 및 뷰 등)의 이름은 소문자여아 합니다. 소문자 이름을 사용하면 따옴표로 묶지 않은 이름이 쿼리 오류를 발생시키지 않게 합니다.

컬럼 이름은 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. cimain 데이터베이스의 마이그레이션을 다운그레이드합니다:

    rake db:migrate:down:main VERSION=<타임스탬프>
    rake db:migrate:down:ci VERSION=<타임스탬프>
    
  2. 마이그레이션 파일을 삭제합니다.
  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을 사용하여 최신 버전의 마이그레이션 도우미를 자동으로 노출시키십시오.

마이그레이션 도우미 및 버전 관리는 GitLab 14.3에서 도입되었습니다. 이전 안정적인 브랜치를 타겟팅하는 머지 요청의 경우, Gitlab::Database::Migration[2.1] 대신에 이전 형식을 사용하고 여전히 ActiveRecord::Migration[6.1]에서 상속받아야 합니다.

데이터베이스 락을 획들할 때 재시도 메커니즘

데이터베이스 스키마를 변경할 때, DDL(Data Definition Language) 문을 호출하는 도우미 메서드를 사용합니다. 이러한 DDL 문은 때때로 특정 데이터베이스 락이 필요합니다.

예시:

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

이 마이그레이션을 실행하려면 users 테이블에 대한 전용 락이 필요합니다. 테이블이 다른 프로세스에 의해 동시에 액세스되고 수정될 경우, 락을 획들기까지 시간이 걸릴 수 있습니다. 락 요청은 대기열에 들어가 있으며, 대기열에 들어간 후 canceling statement due to statement timeout 오류로 실패할 수도 있습니다.

PostgreSQL 락에 대한 자세한 내용: 명시적 락

안정성을 위해 GitLab.com에는 짧은 statement_timeout이 설정되어 있습니다. 마이그레이션을 호출할 때, 설정된 시간 동안 모든 데이터베이스 쿼리가 실행되어야 합니다. 최악의 경우, 요청이 잠시 동안 락 대기열에 있으며, 구성된 statement timeout 기간 동안 다른 쿼리를 차단할 수 있으며, 결국 canceling statement due to statement timeout 오류로 실패할 수 있습니다.

이러한 문제로 인해 실패한 애플리케이션 업그레이드 프로세스 및 심지어 애플리케이션 안정성 문제가 발생할 수 있습니다. 마이그레이션을 처음부터 다시 시도할 수 있는 여러 lock_timeout 설정과 시도 간 대기 시간을 제어하는 방법을 제공합니다. 필요한 락을 획들기 위하여 여러 번 호출하는 더 짧은 시간의 시도는 데이터베이스가 다른 문을 처리할 수 있게 합니다.

락 재시도는 두 가지 다른 도우미에 의해 제어됩니다:

  1. enable_lock_retries!: 모든 transactional 마이그레이션에 대해 기본적으로 활성화됩니다.
  2. with_lock_retries: non-transactional 마이그레이션 내의 블록에 대해 수동으로 활성화됩니다.

트랜잭션 마이그레이션

일반 마이그레이션은 트랜잭션에서 전체 마이그레이션을 실행합니다. 기본적으로 lock-retry 메커니즘이 활성화되어 있습니다 (disable_ddl_transaction!이 아닌 경우).

이로 인해 마이그레이션을 위한 lock 타임아웃이 제어됩니다. 또한 타임아웃 내에 lock이 부여되지 않을 경우 전체 마이그레이션을 다시 시도할 수 있습니다.

가끔 마이그레이션이 여러 객체에 대해 여러 lock을 획득해야 할 수 있습니다. 카탈로그 블로트(catalog bloat)를 방지하려면 DDL을 수행하기 전에 명시적으로 모든 lock을 요청하세요. 더 나은 전략은 마이그레이션을 분할하여 한 번에 하나의 lock만 획들하면 되도록 하는 것입니다.

동일한 테이블에 대한 여러 변경 사항

lock-retry 방법이 활성화되면 모든 작업이 단일 트랜잭션으로 래핑됩니다. lock을 보유한 경우 이후에 다른 lock을 얻으려는 대신에 트랜잭션 내에서 최대한 많은 작업을 수행해야 합니다. 블록 내에서 긴 데이터베이스 문을 실행하는 것에 주의하세요. 획득된 lock은 트랜잭션(블록)이 끝날 때까지 유지되며 lock 유형에 따라 다른 데이터베이스 작업을 블록할 수 있습니다.

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 lock을 필요로 하기 때문에 해당하며, 동일한 트랜잭션 내에서 여러 테이블의 lock을 피해야 합니다.

이를 위해 세 가지 마이그레이션이 필요합니다:

  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 메서드를 사용할 수 있으며, 이 헬퍼 메서드에는 lock 재시도가 내장되어 있습니다.

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

트랜잭션되지 않은 마이그레이션과의 사용

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

사용자 정의 RuboCop 규칙은 lock 재시도 블록 내에 허용된 메서드만 배치할 수 있도록 보장합니다.

disable_ddl_transaction!

def up
  with_lock_retries do
    add_column :users, :name, :text unless column_exists?(:users, :name)
  end

  add_text_limit :users, :name, 255 # 제약 조건 유효성 검사 포함(전체 테이블 스캔)
end

RuboCop 규칙은 일반적으로 표준 Rails 마이그레이션 메서드를 허용하며, 아래에 나열된 메서드가 있습니다. 이 예제는 RuboCop offense를 일으킵니다.

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 메서드 내에서 사용될 수 없으며, 마이그레이션을 가역적으로 만들기 위해 수동으로 updown 메서드를 정의해야 합니다.

도우미 메서드 작동 방식

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

추가 정보는 Gitlab::Database::WithLockRetries 클래스를 확인하세요. with_lock_retries 도우미 메서드는 Gitlab::Database::MigrationHelpers 모듈에서 구현됐습니다.

최악의 경우, 해당 메서드는 다음과 같습니다:

  • 최대 50번까지 40분 동안 블록을 실행합니다.
    • 대부분의 시간은 각 반복 후 미리 구성된 대기 기간에 소비됩니다.
  • 50번의 재시도 후에는 블록이 lock_timeout 없이 표준 마이그레이션 호출처럼 실행됩니다.
  • 락을 획득할 수 없으면, 마이그레이션은 statement timeout 오류로 실패합니다.

매우 오랜 시간 (40분 이상) 동안 실행되는 트랜잭션이 users 테이블에 액세스할 경우 마이그레이션이 실패할 수 있습니다.

SQL 레벨에서의 락 재시도 방법론

이 섹션에서는 lock_timeout의 사용을 보여주는 단순화된 SQL 예제를 제공합니다. 여러 psql 세션에서 주어진 코드를 실행하여 따라 해볼 수 있습니다.

열을 추가하기 위해 테이블을 수정할 때는 대부분의 락 유형과 충돌하는 AccessExclusiveLock이 테이블에 필요합니다. 대상 테이블이 매우 바쁜 경우, 열을 추가하는 트랜잭션이 AccessExclusiveLock을 시간 내에 얻지 못할 수 있습니다.

테이블에 행을 삽입하려는 트랜잭션이 있는 경우를 가정해 봅시다.

-- 트랜잭션 1
BEGIN;
INSERT INTO my_notes (id) VALUES (1);

이 시점에서 트랜잭션 1은 my_notes에서 RowExclusiveLock을 획들했습니다. 트랜잭션 1은 커밋하거나 롤백하기 전에 추가로 명령을 실행할 수 있습니다. my_notes에 대해 접근하는 다른 비슷한 동시 트랜잭션이 있을 수 있습니다.

하나의 열을 추가하는 트랜잭션 마이그레이션이 이러한 락 재시도 도우미를 사용하지 않는 한: sql -- 트랜잭션 2 BEGIN; ALTER TABLE my_notes ADD COLUMN title text;

트랜잭션 2는 이제 블록됐습니다. 왜냐하면 AccessExclusiveLock를 획들하지 못했기 때문입니다. AccessExclusiveLock을 획들하기 위해 my_notes 테이블에서 RowExclusiveLock을 아직 유지하고 있기 때문입니다.

더 치명적인 영향은 트랜잭션 1과 충돌하지 않는 트랜잭션들도 트랜잭션 2 때문에 블록하게 된다는 것입니다. 일반적으로 간단한 상황에서는 트랜잭션이 동일한 시간에 my_notes 테이블에서 읽기와 쓰기를 시도하더라도, 트랜잭션이 RowExclusiveLock에 충돌하지 않는 락을 회피할 수 있기 때문에 트랜잭션이 실행될 수 있습니다. 그러나 AccessExclusiveLock를 획들을 수 있도록 요청이 대기열에 들어가게 되면, 테이블에 대한 충돌 락을 실행하려는 후속 요청이 블록될 것입니다.

with_lock_retries를 사용하면 트랜잭션 2는 설정된 시간 내에 락을 획들지 못하면 빠르게 타임아웃되어 다른 트랜잭션이 진행될 수 있습니다.

-- 트랜잭션 2 (lock timeout 버전)
BEGIN;
SET LOCAL lock_timeout to '100ms'; -- 락 재시도 도우미에 의해 추가됨.
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

이 인덱스가 사용되지 않는지 확인할 수 있습니다. Thanos:

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를 사용하는 것이 좋습니다.

인덱스 비활성화

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

인덱스 추가

인덱스를 추가하기 전에 필요한지 여부를 고려하세요. 데이터베이스 인덱스 추가 안내서에는 인덱스가 필요한지 결정하는 데 도움이 되는 자세한 내용과 인덱스 추가를 위한 모베스트 프랙티스가 포함되어 있습니다.

인덱스의 존재 유무 테스트

마이그레이션에 조건부 논리가 필요한 경우 인덱스의 유무를 이름으로 테스트해야 합니다. 이렇게 하면 Rails가 인덱스 정의를 비교하는 방식에 문제가 발생하는 것을 방지하여 예상치 못한 결과를 초래하는 문제를 피할 수 있습니다.

자세한 내용은 데이터베이스 인덱스 추가 안내서를 확인하세요.

외래 키 제약 조건 추가

기존 열이나 새 열에 외래 키 제약 조건을 추가할 때 해당 열에 인덱스를 추가하는 것을 잊지 마세요.

효율적인 카스케이딩 삭제를 지원하기 위해 모든 외래 키에 대해 이 작업이 필수입니다. 특히 테이블에서 많은 행이 삭제되는 경우 참조된 레코드도 삭제되어야 합니다. 데이터베이스는 참조된 테이블에서 해당 레코드를 찾아야 합니다. 인덱스가 없으면 이 작업은 테이블에서 순차 검색을 수행하게 되어 긴 시간이 소요됩니다.

다음은 외래 키 제약 조건이 포함된 새 열을 추가하는 예시입니다. 인덱스를 생성하기 위해 index: true가 포함되어 있음에 주목하세요.

class Migration < Gitlab::Database::Migration[2.1]

  def change
    add_reference :model, :other_model, index: true, foreign_key: { on_delete: :cascade }
  end
end

비어 있지 않은 테이블에 기존 열에 외래 키 제약 조건을 추가해야 하는 경우 add_reference 대신 add_concurrent_foreign_keyadd_concurrent_index를 사용해야 합니다.

높은 트래픽 테이블을 참조하지 않는 새로운 또는 비어 있는 테이블의 경우 단일 트랜잭션 마이그레이션에서 disable_ddl_transaction!을 필요로 하지 않는 기타 작업과 결합하여 add_reference를 사용하는 것이 좋습니다.

기존 열에 외래 키 제약 조건 추가에 대해 자세히 알아보세요.

NOT NULL 제약 조건

자세한 내용은 NOT NULL 제약 조건 스타일 가이드를 참조하세요.

기본 값이 있는 열 추가

GitLab 13.0 이후로 최소 버전이 PostgreSQL 11인 점을 감안할 때, 기본 값이 있는 열을 추가하는 것은 훨씬 쉬워졌으며 표준 add_column 도우미는 모든 경우에 사용되어야 합니다.

PostgreSQL 11 이전에는 기본 값이 있는 열을 추가하는 것이 문제가 있었는데, 이는 전체 테이블 재작성을 초래했기 때문입니다.

비-nullable 열의 기본값 제거

비-nullable 열을 추가하고 기본 값으로 기존 데이터를 채웠다면, 해당 기본 값을 적어도 응용 프로그램 코드가 업데이트된 후에 지켜야 합니다. 마이그레이션 내에서 기본 값을 제거할 수 없습니다. 왜냐면 마이그레이션은 모델 코드가 업데이트되기 전에 실행되며 모델은 이 열에 대해 알지 못하고 설정할 수 없기 때문입니다. 이 경우에는 다음과 같이 권장됩니다:

  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

이 특정 경우에는 기본값이 이미 존재하고 있는데 이를 변경하는 것이므로 namespaces 테이블에서 모든 기존 레코드를 다시 작성할 필요가 없습니다. 새로운 기본값이 있는 열을 추가할 때에만 모든 레코드가 다시 작성됩니다.

참고: PostgreSQL 11.0에서는 NULL이 아닌 기본값으로 열을 추가하는 ALTER TABLE ADD COLUMN이 빨라졌습니다.

위에서 언급한 이유로, 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을 위한 필수적인 폐기가 필요합니다.

아래 예제는 위와 동일하지만, 값은 barbaz 열의 곱으로 설정됩니다.

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

데이터베이스 테이블 삭제

참고: 테이블을 삭제한 후 데이터베이스 사전 가이드에 따라 데이터베이스 사전에 추가해야 합니다.

데이터베이스 테이블을 삭제하는 것은 흔하지 않으며, Rails가 제공하는 drop_table 메서드가 일반적으로 안전하다고 간주됩니다. 테이블을 삭제하기 전에 다음 사항을 고려하십시오.

당신의 테이블이 고트래픽 테이블에 외래 키가 있는 경우, DROP TABLE 문은 문장 시간 초과 오류가 발생할 때까지 동시 트래픽을 지연시킬 수 있습니다.

테이블에 레코드가 없으며 외래 키가 없는 경우: - 마이그레이션에서 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 테이블에서 외래 키를 제거:

# 첫 번째 마이그레이션 파일
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

테이블 삭제:

# 두 번째 마이그레이션 파일
class DroppingTableMigrationClass < Gitlab::Database::Migration[2.1]
  def up
    drop_table :my_table
  end

  def down
    # 제거된 외래 키 없이 동일한 스키마로 create_table ...
  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

참고: add_sequence는 외래 키가 있는 열에는 피해야 합니다. 이러한 열에 시퀀스를 추가하는 것은 이전 스키마 상태를 복원하는(down) 메서드에서만 허용됩니다.

테이블 잘라내기

테이블을 잘라내는 것은 흔하지 않지만, 데이터베이스 팀에서 제공하는 truncate_tables! 메서드를 사용할 수 있습니다.

실제 동작은 다음과 같습니다.

  • 잘라낼 테이블에 대한 gitlab_schema를 찾습니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schemas에 포함되어 있는 경우, TRUNCATE 문을 실행합니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schemas에 포함되어 있지 않으면 아무 동작도 하지 않습니다.

기본 키 교체

기본 키 교체는 파티션 키를 기본 키에 포함해야 하기 때문에 필요합니다.

데이터베이스 팀에서 제공하는 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

참고: 새로운 인덱스를 소개한 후에 이전에 따로 마이그레이션하면서 기본 키를 교체해야 합니다.

정수 열 유형

기본적으로 정수 열은 최대 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)

문자열 및 텍스트 데이터 유형

더 많은 정보는 텍스트 데이터 유형 스타일 가이드를 참조하세요.

타임스탬프 열 유형

기본적으로 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, :ind_jsonb # indifferent access를 위해 또는 :sym_jsonb을 원하는 경우 키로써 심볼만 필요한 경우.
end

JSONB 열을 사용할 때는 시간이 지남에 따라 삽입되는 데이터를 제어하기 위해 JsonSchemaValidator를 사용하세요.

class BuildMetadata
  validates :config_options, json_schema: { filename: 'build_metadata_config_option' }
end

추가로, JSONB 열의 키를 ActiveRecord 속성으로 노출시킬 수 있습니다. 이것은 복합적인 유효성 검사 또는 ActiveRecord 변경 추적이 필요한 경우에 사용하세요. 이 기능은 jsonb_accessor 젬에 의해 제공되며, 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)
=> #<Organizations::OrganizationSetting:0x0000000148d67628>
> 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]]}

암호화된 속성

데이터베이스에서 attr_encrypted 속성을 :text로 저장하지 마세요. 대신에 :binary를 사용하세요. 이는 PostgreSQL에서 bytea 유형을 사용하며 저장 공간을 더 효율적으로 사용합니다:

class AddSecretToSomething < Gitlab::Database::Migration[2.1]
  def change
    add_column :something, :encrypted_secret, :binary
    add_column :something, :encrypted_secret_iv, :binary
  end
end

이진 열에 암호화된 속성을 저장할 때, attr_encryptedencode: falseencode_iv: false 옵션을 제공해야 합니다:

class Something < ApplicationRecord
  attr_encrypted :secret,
    mode: :per_attribute_iv,
    key: Settings.attr_encrypted_db_key_base_32,
    algorithm: 'aes-256-gcm',
    encode: false,
    encode_iv: false
end

테스트

Testing Rails migrations 스타일 가이드를 참조하세요.

데이터 마이그레이션

전통적인 ActiveRecord 문법 대신 Arel 및 순수 SQL을 선호하세요. 순수 SQL을 사용하는 경우, 모든 입력 값을 수동으로 quote_string 도우미로 인용 처리해야 합니다.

Arel을 사용한 예시:

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

#결과를 사용하여 다른 테이블을 업데이트합니다

순수 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
    # 데이터베이스를 업데이트 하는 모든 모델의 열 정보를 재설정하여 Active Record의 테이블 구조에 대한 지식을 최신 상태로 유지합니다
    Project.reset_column_information

    # ... ...
  end
end

이렇게 하려면 모델의 테이블 이름을 클래스 이름이나 네임스페이스에서 파생되지 않도록 명시적으로 설정해야 합니다.

마이그레이션에서 애플리케이션 코드 사용 시 주의해야 할 사항에 유의하세요.

기존 데이터 수정

대부분의 경우, 데이터베이스에서 데이터를 수정할 때 batch로 데이터를 이관하는 것이 좋습니다.

우리는 컬렉션을 효율적으로 반복하는 과정을 용이하게 해주는 each_batch_range라는 새로운 도우미를 소개했습니다. 배치의 기본 크기는 BATCH_SIZE 상수로 정의됩니다.

아래 예제를 참고하세요.

batch로 데이터 삭제:

include ::Gitlab::Database::DynamicModelHelpers

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)을 활용하는 모델을 사용하는 경우, 특별한 고려 사항이 있습니다.

예제: 사용자 테이블에 my_column 열 추가

User.reset_column_information 명령을 빠트리지 않는 것이 중요하며, 이를 통해 이전 스키마가 캐시에서 삭제되고 ActiveRecord가 업데이트된 스키마 정보를 로드하게 됩니다.

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

  def up
    User.count # 모델에서 컬럼 정보를 캐시하는 모든 ActiveRecord 호출

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

    User.reset_column_information # 이전 스키마가 캐시에서 삭제됨
    User.find_each do |user|
      user.my_column = 42 if some_condition # ActiveRecord가 여기서 올바른 스키마를 볼 수 있음
      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.com과는 다른 사용 패턴으로 GitLab의 다른 기능을 사용할 수 있으므로, GitLab.com을 기반으로 하는 가정은 충분하지 않을 수 있습니다.

GitLab.com의 고트래픽 테이블을 식별하기 위해 다음과 같은 측정 항목이 고려됩니다. 여기에 연결된 메트릭은 GitLab 내부에서만 사용됩니다:

현재의 고트래픽 테이블에 비해 높은 읽기 작업을 가진 모든 테이블이 좋은 후보가 될 수 있습니다.

일반적인 규칙으로, GitLab.com에 대한 분석이나 보고용으로만 사용되는 열을 고트래픽 테이블에 추가하는 것은 권장하지 않습니다. 이는 모든 자체 관리 인스턴스에 부정적인 성능 영향을 미칠 수 있으며 직접적인 기능 가치를 제공하지 않습니다.

마일스톤

GitLab 16.6부터 새로운 마이그레이션은 다음 구문을 사용하여 마일스톤을 지정해야 합니다:

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

  def change
    # 여기에 마이그레이션 작업을 입력하세요
  end
end

올바른 마일스톤을 마이그레이션에 추가하면 해당 GitLab 마이너 버전에 따라 마이그레이션을 논리적으로 분할할 수 있습니다. 이는 다음과 같은 장점을 제공합니다:

  • 업그레이드 과정을 단순화합니다.
  • 마이그레이션의 타임스탬프만을 사용하여 순서를 정하는 경우 발생할 수 있는 잠재적인 마이그레이션 순서 문제를 완화시킵니다.

Autovacuum wraparound protection

이것은 PostgreSQL을 위한 특별한 autovacuum 실행 모드로, 이는 청소하는 테이블에 대해 ShareUpdateExclusiveLock을 필요로 합니다. 더 큰 테이블에 대해서는 몇 시간이 걸릴 수 있으며, 잠금은 테이블을 동시에 수정하려는 대부분의 DDL 마이그레이션과 충돌할 수 있습니다. 마이그레이션이 충돌하는 경우, 시간 내에 잠금을 획들어낼 수 없으므로 실패하고 배포를 차단할 것입니다.

포스트-디플로이 마이그레이션(PDM) 파이프라인은 마이그레이션이 실행 전에 테이블에서 wraparound 방지 vacuum 프로세스를 감지하고 실행을 중단할 수 있습니다. 이를 위해 마이그레이션 이름에 완전한 테이블 이름을 사용해야 합니다. 예를 들어 add_foreign_key_between_ci_builds_and_ci_job_artifacts는 마이그레이션을 실행하기 전에 ci_buildsci_job_artifacts에서 vacuum을 확인할 것입니다.

마이그레이션에 충돌하는 잠금이 없는 경우, vacuum 확인을 건너뛸 수 있습니다. 예를 들어 create_async_index_on_job_artifacts와 같이 완전한 테이블 이름을 사용하지 않으면 됩니다.