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

GitLab의 마이그레이션을 작성할 때는 이 마이그레이션이 데이터베이스에 많은 년의 데이터가 있는 다양한 규모의 수십만 개 조직에 의해 실행된다는 점을 고려해야 합니다.

또한, 업그레이드를 위해 서버를 오프라인으로 전환해야 하는 것은 대부분의 조직에 큰 부담이 됩니다. 이러한 이유로, 마이그레이션은 주의 깊게 작성되어야 하며, 온라인에서 적용할 수 있어야 하고, 아래의 스타일 가이드를 준수해야 합니다.

마이그레이션은 절대 GitLab 설치를 오프라인 상태로 요구할 수 없습니다. 마이그레이션은 항상 다운타임을 피할 수 있도록 작성되어야 합니다. 과거에는 DOWNTIME 상수를 설정하여 다운타임을 허용하는 마이그레이션 정의 프로세스가 있었고, 이는 이전 마이그레이션을 볼 때 확인할 수 있습니다. 이 프로세스는 4년 동안 사용되지 않았으며, 이를 통해 우리는 다운타임을 피하기 위해 마이그레이션을 다르게 작성하는 방법을 항상 찾을 수 있다는 것을 배웠습니다.

마이그레이션을 작성할 때 데이터베이스에 오래된 데이터나 불일치가 있을 수 있음을 고려하고 이에 대한 방어를 해야 합니다. 데이터베이스 상태에 대한 가정은 가능한 한 적게 하세요.

GitLab 특정 코드를 의존하지 마세요. 미래 버전에서 변경될 수 있습니다. 필요한 경우 마이그레이션에 GitLab 코드를 복사하여 붙여넣어 앞으로 호환되도록 하세요.

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

새로운 마이그레이션을 추가하기 전에 가장 적합한 유형을 결정하는 것이 첫 번째 단계입니다.

현재 수행해야 할 작업의 종류와 완료되는 데 걸리는 시간에 따라 생성할 수 있는 세 가지 종류의 마이그레이션이 있습니다:

  1. 정상 스키마 마이그레이션. 이는 db/migrate의 전통적인 Rails 마이그레이션으로, 새로운 애플리케이션 코드가 배포되기 _전_에 실행됩니다 (GitLab.com의 경우 카나리아가 배포되기 전). 이는 상대적으로 빠르게 실행되어야 하며, 몇 분을 넘지 않아야 배포 지연이 불필요하게 발생하지 않습니다.

    단점은 애플리케이션이 올바르게 작동하기 위해 절대적으로 중요한 마이그레이션이 있습니다. 예를 들어, 고유한 튜플을 강제하거나 애플리케이션의 중요한 부분에서 쿼리 성능을 위해 필요한 인덱스가 있을 수 있습니다. 마이그레이션이 용납할 수 없을 정도로 느린 경우, 대신 기능 플래그로 기능을 보호하고 배포 후 마이그레이션을 수행하는 것이 더 좋은 선택일 수 있습니다. 그런 다음 마이그레이션이 끝난 후 기능을 켤 수 있습니다.

    새로운 모델을 추가하는 데 사용되는 마이그레이션도 이러한 정상 스키마 마이그레이션의 일부입니다. 유일한 차이점은 마이그레이션을 생성하는 데 사용되는 Rails 명령과 생성된 추가 파일(모델과 모델의 스펙에 대한 파일)이 있습니다.

  2. 배포 후 마이그레이션. 이는 db/post_migrate의 Rails 마이그레이션으로, GitLab.com 배포와 독립적으로 실행됩니다. 보류 중인 포스트 마이그레이션은 릴리스 관리자의 재량에 따라 하루에 한 번 실행됩니다 배포 후 마이그레이션 파이프라인 통해. 이 마이그레이션은 애플리케이션이 작동하는 데 필수적이지 않은 스키마 변경 또는 최대 몇 분이 걸리는 데이터 마이그레이션에 사용할 수 있습니다. 배포 후 실행되어야 하는 스키마 변경의 일반적인 예는:

    • 사용하지 않는 열을 제거하는 등의 정리 작업.
    • 높은 트래픽 테이블에 대한 비필수 인덱스 추가.
    • 생성하는 데 오랜 시간이 걸리는 비필수 인덱스 추가.

    이러한 마이그레이션은 애플리케이션이 작동하는 데 필수적인 스키마 변경에 사용되어서는 안 됩니다. 배포 후 마이그레이션에서 이러한 스키마 변경을 수행하는 것은 과거에 문제를 일으켰습니다. 예를 들어, 이 문제. 항상 정상 스키마 마이그레이션이어야 하는 변경 사항은 다음과 같이 배포 후 마이그레이션에서 실행되지 않아야 합니다:

    • 새로운 테이블 생성, 예: create_table.
    • 기존 테이블에 새 열 추가, 예: add_column.

    참고: 배포 후 마이그레이션은 종종 PDM으로 축약됩니다.

  3. 배치 백그라운드 마이그레이션. 이는 일반적인 Rails 마이그레이션이 아니며, Sidekiq 작업을 통해 실행되는 애플리케이션 코드입니다. 그러나 배포 후 마이그레이션이 이를 예약하는 데 사용됩니다. 포스트 배포 마이그레이션의 타이밍 가이드라인을 초과하는 데이터 마이그레이션에 대해서만 사용하세요. 배치 백그라운드 마이그레이션은 스키마를 변경해서는 됩니다.

다음 다이어그램을 사용하여 결정하는 데 도움을 받되, 이는 단지 도구일 뿐이며 최종 결과는 항상 수행되는 특정 변경 사항에 따라 달라질 것임을 명심하세요:

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

데이터베이스 인덱스를 추가할 때 어떤 마이그레이션 유형을 사용할지 선택할 때는 사용할 마이그레이션 유형도 참조하세요.

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

일반적으로, 단일 배포를 위한 모든 마이그레이션은 GitLab.com에서 1시간을 초과하지 않아야 합니다.

다음 지침은 엄격한 규칙이 아닙니다. 마이그레이션 기간을 최소화하기 위해 추정된 것입니다.

note
모든 기간은 GitLab.com을 기준으로 측정해야 합니다.
note
데이터베이스 마이그레이션 파이프라인의 결과에는 마이그레이션에 대한 시간 정보가 포함됩니다.
마이그레이션 유형 권장 기간 비고
일반 마이그레이션 <= 3 분 유효한 예외는 애플리케이션 기능이나 성능이 심각하게 저하될 수 있는 변경 사항이며 지연될 수 없습니다.
배포 후 마이그레이션 <= 10 분 유효한 예외는 스키마 변경입니다. 이는 백그라운드 마이그레이션에서 발생해서는 안 됩니다.
백그라운드 마이그레이션 > 10 분 이러한 마이그레이션은 더 큰 테이블에 적합하므로 정확한 시간 지침을 설정할 수 없습니다. 그러나 모든 단일 쿼리는 냉 캐시에서 1 초 실행 시간 이하로 유지해야 합니다.

어떤 데이터베이스를 타겟으로 할지 결정하기

GitLab은 두 개의 서로 다른 Postgres 데이터베이스인 mainci에 연결됩니다.

이 분할은 마이그레이션에 영향을 미칠 수 있으며, 마이그레이션은 이 두 데이터베이스 중 하나 또는 둘 모두에서 실행될 수 있습니다.

다중 데이터베이스에 대한 마이그레이션을 읽어 추가한 마이그레이션이 이를 고려해야 하는지 여부를 이해하세요.

일반 스키마 마이그레이션 생성하기

마이그레이션을 생성하려면 다음 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을 사용하는 다른 사람에게 혼란을 초래합니다.

완료되면, 인덱스를 추가하는 머지 리퀘스트에 add_concurrent_index로 스키마 변경 사항을 커밋합니다.

GDK의 로컬 데이터베이스가 main 스키마와 다르면, 스키마 변경 사항을 Git에 깔끔하게 커밋하기 어려울 수 있습니다.

이 경우, scripts/regenerate-schema 스크립트를 사용하여 추가하는 마이그레이션에 대한 깔끔한 db/structure.sql을 재생성할 수 있습니다.

이 스크립트는 db/migrate 또는 db/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 파일 및 데이터베이스의 일관성을 보장하려고 할 때 사용됩니다.

마이그레이션에 가역성을 테스트한 방법을 설명하는 주석을 추가하세요.

일부 마이그레이션은 되돌릴 수 없습니다. 예를 들어, 일부 데이터 마이그레이션은 마이그레이션 이전 데이터베이스 상태에 대한 정보가 손실되기 때문에 되돌릴 수 없습니다.

그럼에도 불구하고 down 메서드를 생성하고, up 메서드에 의해 수행된 변경 사항이 왜 되돌릴 수 없는지 설명하는 주석을 추가해야 합니다. 이렇게 하면 마이그레이션 자체는 되돌릴 수 있지만, 마이그레이션 중에 수행된 변경 사항은 도로로 불가능하게 됩니다:

def down
  # no-op

  # `up`에 의해 수행된 변경 사항이 되돌릴 수 없는 이유를 설명하는 주석.
end

이와 같은 마이그레이션은 본질적으로 위험하며, 데이터 마이그레이션 추가 작업이 필요합니다.

원자성과 트랜잭션

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

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

따라서 다음 중 하나를 선택하십시오:

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

예를 들어 빈 테이블을 생성하고 그에 대한 인덱스를 구축해야 하는 경우, 일반적인 단일 트랜잭션 마이그레이션과 기본 rails 스키마 문을 사용해야 합니다: add_index.

이 작업은 차단 작업이지만, 테이블이 아직 사용되지 않기 때문에 문제를 일으키지 않으며, 따라서 레코드가 없습니다.

참고:

일반적으로 서브트랜잭션은 허용되지 않음입니다.

필요한 경우 단일 트랜잭션에서의 무거운 작업에 설명된 대로 여러 개의 별도 트랜잭션을 사용하십시오.

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

단일 트랜잭션 마이그레이션을 사용할 때, 트랜잭션은 마이그레이션 기간 동안 데이터베이스 연결을 유지하므로, 마이그레이션의 작업이 너무 오랜 시간이 걸리지 않도록 해야 합니다.

일반적으로 트랜잭션은 빠르게 실행되어야 합니다.

그 목표를 위해, 마이그레이션 실행 중에 실행되는 각 쿼리에 대해 최대 쿼리 시간 제한을 준수해야 합니다.

단일 트랜잭션 마이그레이션이 오랫동안 완료되지 않는 경우, 여러 가지 옵션이 있습니다.

모든 경우에, 마이그레이션이 소요되는 시간에 따라 적절한 마이그레이션 유형을 선택해야 한다는 점을 기억하세요 어떤 마이그레이션이 소요되는지.

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

  • 사용하여 여러 트랜잭션합니다. disable_ddl_transaction!

  • 진술 및 잠금 시간 초과 설정 조정 후 단일 트랜잭션 마이그레이션을 계속 사용합니다.

    무거운 작업 부하가 트랜잭션의 보장을 필요로 하는 경우, 마이그레이션이 시간 초과 제한에 도달하지 않고 실행할 수 있는지 확인해야 합니다.

    이 조언은 단일 트랜잭션 마이그레이션과 개별 트랜잭션 모두에 적용됩니다.

    • Statement timeout: statement timeout은 GitLab.com의 운영 데이터베이스에 대해 15s로 설정되지만, 인덱스를 생성하는 데에는 종종 15초 이상 걸립니다.

      add_concurrent_index를 포함한 기존 헬퍼를 사용할 때는 필요에 따라 자동으로 statement timeout을 끕니다.

      드물게, 사용하여 타임아웃 한도를 수동으로 설정해야 할 수도 있습니다. disable_statement_timeout

note
마이그레이션을 실행하기 위해, 우리는 PgBouncer를 우회하여 기본 데이터베이스에 직접 연결하여 statement_timeoutlock_wait_timeout과 같은 설정을 제어합니다.

Statement timeout 한도 일시적으로 끄기

마이그레이션 헬퍼 disable_statement_timeout을 사용하면 각 트랜잭션 또는 각 연결에 대해 statement timeout을 일시적으로 0으로 설정할 수 있습니다.

  • 명시적 트랜잭션 내에서 실행을 지원하지 않는 경우, 연결 당 옵션을 사용합니다. CREATE INDEX CONCURRENTLY

  • 명시적 트랜잭션 블록을 지원하는 경우처럼, ALTER TABLE ... VALIDATE CONSTRAINT, 트랜잭션 당 옵션을 사용해야 합니다.

disable_statement_timeout을 사용해야 할 일은 드물며, 대부분의 마이그레이션 헬퍼는 필요할 때 이를 내부적으로 사용합니다.

예를 들어, 인덱스를 생성하는 데 일반적으로 15초 이상 걸리며, 이는 GitLab.com 운영 데이터베이스에 설정된 기본 statement timeout입니다.

헬퍼 add_concurrent_index는 statement timeout을 비활성화하기 위해 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의 트랜잭션에 대한 문서를 참조하세요.

note
GitLab에서는 종종 disable_ddl_transaction!을 사용한 마이그레이션을 비트랜잭션 마이그레이션이라고 언급했습니다.

이는 마이그레이션이 단일 트랜잭션으로 실행되지 않았다는 의미입니다.

disable_ddl_transaction!을 언제 사용해야 할까요? 대부분의 경우, 기존 RuboCop 규칙이나 마이그레이션 헬퍼가 이를 사용해야 하는지 감지할 수 있습니다.

마이그레이션에서 사용할지 여부가 확실하지 않은 경우 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 트랜잭션 내에서 외부 서비스에 상호작용할 수 없습니다.

  • 트랜잭션은 단일 데이터베이스 연결을 위해 사용됩니다.

    마이그레이션이 cimain 데이터베이스와 같이 여러 데이터베이스를 대상으로 할 경우, 여러 데이터베이스를 위한 마이그레이션을 따르세요.

명명 규칙

데이터베이스 객체(테이블, 인덱스, 뷰 등)의 이름은 소문자여야 합니다.

소문자 이름은 쿼리에서 인용되지 않은 이름이 오류를 일으키지 않도록 보장합니다.

열 이름은 ActiveRecord의 스키마 규칙에 일치하도록 유지합니다.

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

긴 인덱스 이름 잘라내기

PostgreSQL은 식별자의 길이를 제한합니다,

열 또는 인덱스 이름과 같은 것입니다. 열 이름은 일반적으로 문제가 되지 않지만, 인덱스 이름은

더 긴 경향이 있습니다. 너무 긴 이름을 줄이는 몇 가지 방법은 다음과 같습니다:

  • index_ 대신 i_로 접두사를 붙입니다.
  • 중복된 접두사는 생략합니다. 예를 들어, index_vulnerability_findings_remediations_on_vulnerability_remediation_idindex_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=<timestamp>
    rake db:migrate:down:ci VERSION=<timestamp>
    
  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을 사용하세요.

데이터베이스 잠금을 획득할 때의 재시도 메커니즘

데이터베이스 스키마를 변경할 때, 우리는 DDL(데이터 정의 언어) 문을 호출하기 위해 헬퍼 메서드를 사용합니다. 이러한 DDL 문은 특정 데이터베이스 잠금을 요구할 수 있습니다.

예시:

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

이 마이그레이션을 실행하려면 users 테이블에 대한 독점 잠금이 필요합니다. 테이블이 다른 프로세스에 의해 동시에 접근 및 수정될 때, 잠금을 획득하는 데 시간이 걸릴 수 있습니다. 잠금 요청은 대기열에서 대기하며, 일단 대기열에 들어가면 users 테이블에 대한 다른 쿼리를 차단할 수 있습니다.

PostgreSQL 잠금에 대한 추가 정보: Explicit Locking

안정성 이유로, GitLab.com에서는 짧은 statement_timeout이 설정되어 있습니다. 마이그레이션이 호출될 때, 모든 데이터베이스 쿼리는 실행할 고정 시간이 있습니다. 최악의 경우, 요청은 잠금 대기열에 앉아 있어, 설정된 statement timeout 기간 동안 다른 쿼리를 차단하다가 canceling statement due to statement timeout 오류로 실패합니다.

이 문제는 애플리케이션 업그레이드 프로세스가 실패하거나 애플리케이션 안정성 문제를 일으킬 수 있습니다. 테이블이 잠시 동안 접근할 수 없게 될 수 있기 때문입니다.

데이터베이스 마이그레이션의 신뢰성과 안정성을 높이기 위해, GitLab 코드베이스는 다양한 lock_timeout 설정과 시도 간 대기 시간을 사용하여 작업을 재시도하는 메서드를 제공합니다. 필요한 잠금을 얻기 위한 여러 개의 짧은 시도는 데이터베이스가 다른 문을 처리할 수 있도록 합니다.

잠금 재시도는 두 가지 다른 헬퍼에 의해 제어됩니다:

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

트랜잭션 마이그레이션

일반적인 마이그레이션은 전체 마이그레이션을 트랜잭션 내에서 실행합니다. 잠금-재시도 메커니즘은 기본적으로 활성화되어 있습니다(단, 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

열의 기본값 변경

열 기본값을 변경하면 다수 릴리스 프로세스를 따르지 않을 경우 애플리케이션 다운타임을 유발할 수 있습니다. 열 기본값을 변경하는 마이그레이션에서 다운타임을 피하는 방법에 대한 자세한 내용은 avoiding downtime in migrations for changing column defaults를 참조하세요.

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

비트랜잭션 마이그레이션과 함께 사용할 때

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 # 제약 조건 검증 포함(전체 테이블 스캔)
end

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

disable_ddl_transaction!

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

헬퍼 메서드를 사용할 때

실행이 이미 열려 있는 트랜잭션 내에 있지 않을 때만 with_lock_retries 헬퍼 메서드를 사용할 수 있습니다(포스트그레스SQL 서브트랜잭션 사용은 권장되지 않습니다). 표준 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 오류로 실패합니다.

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

SQL 수준에서의 잠금 재시도 방법론

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

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

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

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

이 시점에서 트랜잭션 1은 my_notesRowExclusiveLock을 획득했습니다.
트랜잭션 1은 커밋하거나 중단하기 전에 여전히 더 많은 문을 실행할 수 있습니다.
my_notes에 대해 유사한 다른 동시 트랜잭션이 있을 수 있습니다.

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

-- 트랜잭션 2
BEGIN;
ALTER TABLE my_notes ADD COLUMN title text;

트랜잭션 2는 이제 my_notes 테이블에서 AccessExclusiveLock을 획득할 수 없기 때문에 차단됩니다.
트랜잭션 1이 여전히 실행 중이며 my_notes에 대해 RowExclusiveLock을 보유하고 있기 때문입니다.

더 해로운 효과는 일반적으로 트랜잭션 1과 충돌하지 않을 트랜잭션을 차단하는 것입니다.
트랜잭션 2가 AccessExclusiveLock을 획득하기 위해 대기 중이기 때문입니다.
일반적인 상황에서는 다른 트랜잭션이 트랜잭션 1과 동시에 동일한 테이블 my_notes에서 읽고 쓰기를 시도하면,
트랜잭션은 통과할 수 있습니다.
읽기 및 쓰기에 필요한 잠금이 트랜잭션 1이 보유한 RowExclusiveLock과 충돌하지 않기 때문입니다.
그러나 AccessExclusiveLock을 획득하려는 요청이 대기 중일 경우,
테이블에 대한 충돌 잠금 요청은 차단됩니다.
그들은 트랜잭션 1과 동시에 실행될 수 있지만, 요청이 대기 중에 있어 차단됩니다.

with_lock_retries를 사용했다면, 트랜잭션 2는 지정된 시간 내에 잠금을 획득하지 못하면
빠르게 시간 초과되어 다른 트랜잭션이 진행될 수 있습니다:

-- 트랜잭션 2 (잠금 시간 초과가 포함된 버전)
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

인덱스가 사용되지 않고 있는지 확인하려면 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개 미만의 레코드를 가진 테이블)의 경우 remove_index를 단일 트랜잭션 마이그레이션에서 사용하는 것이 좋으며, disable_ddl_transaction!이 필요하지 않은 다른 작업과 결합할 수 있습니다.

인덱스 비활성화

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

인덱스 추가

인덱스를 추가하기 전에 인덱스가 필요한지 고려하세요. 데이터베이스 인덱스 추가하기 가이드는 인덱스가 필요한지 결정하는 데 도움이 되는 자세한 내용을 포함하며, 인덱스 추가를 위한 모범 사례를 제공합니다.

인덱스 존재 여부 검사

마이그레이션에서 인덱스의 존재 또는 부재에 따라 조건 논리가 필요한 경우, 인덱스 이름을 사용하여 해당 인덱스의 존재 여부를 검사해야 합니다. 이는 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_concurrent_foreign_keyadd_concurrent_index를 사용해야 하며 add_reference를 사용할 수 없습니다.

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

기존 열에 외래 키 제약 조건을 추가하는 방법에 대한 자세한 내용은 기존 열에 외래 키 제약 조건 추가하기에서 확인할 수 있습니다.

NOT NULL 제약 조건

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

기본값이 있는 열 추가하기

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

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

널 불가 열의 기본값 제거

널 불가 열을 추가하고 기존 데이터를 채우기 위해 기본값을 사용한 경우, 애플리케이션 코드가 업데이트될 때까지 기본값을 유지해야 합니다. 같은 마이그레이션 내에서 기본값을 제거할 수 없습니다. 왜냐하면 마이그레이션은 모델 코드가 업데이트되기 전에 실행되며, 모델은 이전 스키마 캐시를 가지고 있어 이 열을 알지 못하고 설정할 수 없기 때문입니다. 이 경우 다음을 권장합니다:

  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 테이블의 모든 기존 기록 재작성을 의미하지 않습니다. 기본값과 함께 새로운 열을 생성할 때만 모든 기록이 재작성됩니다.

참고: PostgreSQL 11.0에 도입된 더 빠른 ALTER TABLE ADD COLUMN with a non-null default로 인해 기본값이 있는 새 열을 추가할 때 테이블을 재작성할 필요가 없게 되었습니다.

위의 이유로 인해, change_column_default를 단일 트랜잭션 마이그레이션에서 사용하는 것은 안전하며 disable_ddl_transaction!이 필요하지 않습니다.

기존 열 업데이트

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

아래는 projects 테이블의 foo 열을 some_column'hello'인 경우 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

데이터베이스 테이블 삭제

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 테이블에서 외래 키를 비트랜잭셔널 마이그레이션을 사용하여 제거하는 예:

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

참고: 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

참고: 기본 키를 교환하기 위해서는 미리 새로운 인덱스를 별도의 마이그레이션에서 도입해야 합니다.

정수 열 유형

기본적으로, 정수 열은 최대 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 # 구분 없는 접근을 위해 또는 기호만 키로 필요할 경우 :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}'는 유효한 가시성 수준이 아닙니다."), 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

테스트

테스트 Rails 마이그레이션 스타일 가이드를 참조하십시오.

데이터 마이그레이션

일반적인 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

이럴 경우 모델의 테이블 이름을 명시적으로 설정하여 클래스 이름이나 네임스페이스로부터 유도되지 않도록 하십시오.

마이그레이션에서 모델 사용 시의 한계를 인지하십시오.

기존 데이터 수정

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

우리는 컬렉션을 효율적으로 반복할 수 있도록 each_batch_range라는 새로운 헬퍼를 도입했습니다. 배치의 기본 크기는 BATCH_SIZE 상수로 정의됩니다.

다음 예제를 참조하십시오.

배치로 데이터 정리하기:

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.

다음은 우리의 새로운 헬퍼를 사용하는 방법을 보여주는 예제 MR입니다.

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

마이그레이션에서 애플리케이션 코드(모델 포함)의 사용은 일반적으로 권장하지 않습니다. 이는 마이그레이션이 오랫동안 존재하며 의존하는 애플리케이션 코드가 변경되어 이후에 마이그레이션이 깨질 수 있기 때문입니다. 과거에 일부 백그라운드 마이그레이션은 여러 파일에 퍼져 있는 수백 줄의 코드를 마이그레이션으로 복사하지 않기 위해 애플리케이션 코드를 사용해야 했습니다. 이러한 드문 경우에는 마이그레이션에 좋은 테스트가 포함되어 있어야 하며, 이를 통해 미래에 코드를 리팩토링하는 이가 마이그레이션이 깨졌는지를 알 수 있습니다. 애플리케이션 코드 사용은 배치 백그라운드 마이그레이션에서도 권장되지 않으며, 모델은 마이그레이션에서 선언되어야 합니다.

보통은 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의 다양한 기능을 사용할 수 있으므로 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 마이너 버전으로 논리적으로 분리할 수 있습니다. 이는:

  • 업그레이드 프로세스를 단순화합니다.
  • 마이그레이션의 타임스탬프에만 의존하여 발생하는 잠재적인 마이그레이션 순서 문제를 완화합니다.

자동 진공 감쇄 보호

이는 PostgreSQL에 대한 특별 자동 진공 실행 모드이며, 진공 처리를 수행하는 테이블에 대해 ShareUpdateExclusiveLock을 요구합니다. 더 큰 테이블의 경우, 이는 몇 시간이 걸릴 수 있으며, 이 잠금은 동시에 테이블을 수정하려는 대부분의 DDL 마이그레이션과 충돌할 수 있습니다. 마이그레이션이 제 시간에 잠금을 획득하지 못하면 실패하고 배포가 차단됩니다.

배포 후 마이그레이션(PDM) 파이프라인은 테이블 중 하나에서 감쇄 방지 진공 프로세스가 감지되면 실행을 중단할 수 있습니다. 이를 위해서는 마이그레이션 이름에 전체 테이블 이름을 사용해야 합니다. 예를 들어 add_foreign_key_between_ci_builds_and_ci_job_artifacts는 마이그레이션을 실행하기 전에 ci_buildsci_job_artifacts에 대한 진공을 확인합니다.

마이그레이션에 충돌하는 잠금이 없으면 전체 테이블 이름을 사용하지 않고 진공 검사를 건너뛸 수 있습니다. 예를 들어 create_async_index_on_job_artifacts.