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

GitLab의 마이그레이션을 작성할 때, 각종 규모의 수백만 개 조직이 운영 중인데다가 데이터베이스에는 몇 년치의 데이터가 쌓여 있을 수 있다는 점을 고려해야 합니다.

또한, 작은 변경이나 큰 업그레이드를 위해 서버를 오프라인 상태로 전환하는 것은 대부분의 조직에 큰 부담이 됩니다. 그러므로 마이그레이션은 신중히 작성되어야 하며 온라인으로 적용할 수 있도록 작성되어야 하며 아래 스타일 가이드에 따라야 합니다.

마이그레이션은 어떤 경우에도 GitLab 설치를 오프라인 상태로 만들어서는 안됩니다. 과거에는 마이그레이션을 정의하는 프로세스가 있었는데, 이 프로세스에서는 DOWNTIME 상수를 설정하여 다운타임을 허용했던 적이 있습니다. 과거의 마이그레이션을 살펴보면 이 내용을 볼 수도 있습니다. 이 프로세스는 4년 동안 사용되지 않은 채로 남아 있었고, 따라서 언제나 다른 방식으로 마이그레이션을 작성하여 다운타임을 피할 수 있다는 것을 배웠습니다.

마이그레이션을 작성할 때는 데이터베이스에는 부정확한 데이터나 일관성이 없는 데이터가 있을 수 있으므로 이 점을 고려하고 가드 처리해야 합니다. 데이터베이스 상태에 대한 가정을 최소화하려고 노력해야 합니다.

향후 버전에서 변경될 가능성이 있는 GitLab 특정 코드에 의존해서는 안 됩니다. 필요한 경우 마이그레이션에 GitLab 코드를 복사하여 앞으로 사용할 수 있도록 만들어야 합니다.

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

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

현재 생성할 수 있는 마이그레이션 유형은 작업 내용 및 완료에 걸리는 시간에 따라 다음과 같이 세 가지 유형이 있습니다:

  1. 일반 스키마 마이그레이션. 이것들은 db/migrate에 있는 전통적인 Rails 마이그레이션으로, 새로운 애플리케이션 코드가 배포되기 에 실행됩니다(GitLab.com의 경우, Canary가 배포되기 전). 따라서 배포를 불필요하게 지연시키지 않도록, 상대적으로 빠르게 완료되어야 합니다. 단, 애플리케이션이 올바르게 작동하도록 만드는 데 절대적으로 중요하지만 마이그레이션이 오래 걸리는 경우에는 피처 플래그를 사용하여 기능을 가드하고 배포 후 마이그레이션을 수행하는 것이 더 나을 수도 있습니다. 마이그레이션이 완료된 후 기능을 켜면 됩니다.
  2. 배포 후 마이그레이션. 이것들은 db/post_migrate에 있는 Rails 마이그레이션으로써, GitLab.com 배포와는 독립적으로 실행됩니다. 보류 중인 마이그레이션은 릴리스 관리자의 재량으로 배포 후 마이그레이션 파이프라인을 통해 매일 실행됩니다. 이러한 마이그레이션은 응용프로그램의 작동에 중대하지 않은 스키마 변경이나 최대 몇 분이 소요되는 데이터 마이그레이션에 사용될 수 있습니다.
  3. 일괄 배경 마이그레이션. 이것은 일반 Rails 마이그레이션이 아니라 Sidekiq 작업을 통해 실행되는 응용프로그램 코드이지만, 배포 후 마이그레이션을 예약하는 데 사용됩니다. 이는 배포 후 마이그레이션의 시간 가이드라인을 초과하는 데이터 마이그레이션에만 사용되어야 합니다. 일괄 배경 마이그레이션은 스키마를 변경해서는 안 됩니다.

다음 다이어그램을 사용하여 결정을 안내하되, 항상 특정 변경 사항에 따라 최종 결과가 항상 의존해야 한다는 점을 명심하십시오:

graph LR A{스키마가<br/>변경됨?} A -->|예| C{속도 또는<br/>동작에 중요함?} A -->|아니요| D{빠른가요?} C -->|예| H{빠릅니까?} C -->|아니요| F[배포 후 마이그레이션] H -->|예| E[일반 마이그레이션] H -->|아니요| I[배포 후 마이그레이션<br/>+ 피처 플래그] D -->|예| F[배포 후 마이그레이션] D -->|아니요| G[배경 마이그레이션]

마이그레이션이 소요해야 하는 시간

일반적으로, 단일 배포에 대한 모든 마이그레이션의 소요 시간은 GitLab.com에 대해 1시간을 넘어서는 안됩니다. 다음 가이드라인은 엄격한 규칙은 아니지만, 마이그레이션 기간을 최소화하기 위해 추정되었습니다.

note
모든 기간은 GitLab.com에 맞춰 메트릭되어야 한다는 것을 명심하십시오.
마이그레이션 유형 추천 기간 참고
일반 마이그레이션 <= 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에 spec 파일

스키마 변경

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

note
비동기적으로 인덱스를 생성하려면 두 개의 Merge Request이 필요합니다. 완료되면, 인덱스를 추가하는 마이그레이션과 함께 스키마 변경을 커밋하세요.

당신의 로컬 데이터베이스가 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을 사용하여 마이그레이션을 되돌리는 대신 롤-포워드 전략이 사용됩니다. Self-managed 인스턴스에서는 업그레이드 프로세스가 시작되기 전에 생성된 백업을 복원하는 것을 권장합니다. down 메서드는 주로 개발 환경에서 사용되며, 예를 들어 개발자가 커밋이나 브랜치 간에 전환할 때 로컬 structure.sql 파일과 데이터베이스가 일관된 상태인지 확인하려는 경우에 사용됩니다.

당신의 마이그레이션에는 마이그레이션의 되돌릴 수 있는 여부를 테스트한 설명을 추가하세요.

일부 마이그레이션은 되돌릴 수 없습니다. 예를 들어, 일부 데이터 마이그레이션은 데이터베이스의 상태에 대한 정보를 잃기 때문에 되돌릴 수 없습니다. 그럼에도 불구하고 up 메서드에 의해 수행된 변경 사항이 되돌려질 수 없는 이유를 설명하는 주석을 포함하여 down 메서드를 생성해야 하며, 이렇게 하면 마이그레이션 자체는 되돌릴 수 있게 됩니다. 마이그레이션 중에 수행된 변경 사항을 되돌릴 수 없더라도 이렇게 하면 마이그레이션 자체는 되돌릴 수 있게됩니다.

def down
  # 아무 동작도 수행하지 않음
  
  # `up` 메서드에 의해 수행된 변화를 되돌릴 수 없는 이유를 설명하는 주석
end

이러한 마이그레이션은 본질적으로 위험하며, 데이터 마이그레이션을 추가할 때 준비 단계에서 추가 조치가 필요합니다.

원자성과 트랜잭션

기본적으로 마이그레이션은 단일 트랜잭션입니다: 마이그레이션을 시작하면 오픈되어 모든 단계가 처리된 후에 커밋됩니다.

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

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

예를 들어, 빈 테이블을 생성하고 그에 대한 인덱스를 작성해야 하는 경우, 일반 단일 트랜잭션 마이그레이션과 기본적인 rails 스키마 문을 사용해야 합니다: add_index. 이 작업은 블로킹 작업이지만, 아직 테이블이 사용되지 않고 있기 때문에 레코드도 없습니다.

참고: 일반적으로 서브트랜잭션이 허용되지 않습니다. 필요한 경우, 단일 트랜잭션에서의 복잡한 작업에 설명된 대로 다수의 별도의 트랜잭션을 사용하세요.

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

단일 트랜잭션 마이그레이션을 사용할 때, 트랜잭션이 마이그레이션의 기간 동안 데이터베이스 연결을 유지하므로 마이그레이션 내의 작업이 너무 많은 시간을 차지하지 않도록해야 합니다. 보통 트랜잭션은 빨리 실행해야 합니다. 이를 위해 마이그레이션에서 실행되는 각 쿼리에 대한 최대 쿼리 시간 제한을 준수해야 합니다.

단일 트랜잭션 마이그레이션이 오래 걸린다면 여러 가지 옵션이 있습니다. 모든 경우에 해당하는 적절한 마이그레이션 유형을 선택해야 합니다. 마이그레이션이 얼마 동안이나 걸리는지에 따라 선택하세요.

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

  • disable_ddl_transaction!을 사용하여 여러 트랜잭션을 사용하세요.

  • 문장 및 잠금 시간 초과 설정을 조정한 후에도 단일 트랜잭션 마이그레이션을 계속 사용합니다. 무거운 작업이 트랜잭션의 보장을 사용해야 할 경우 마이그레이션이 제한 시간을 초과하지 않고 실행될 수 있는지 확인해야 합니다. 이 조언은 단일 트랜잭션 마이그레이션뿐만 아니라 개별 트랜잭션에도 해당됩니다.

    • 문장 시간 초과: 문장 시간 초과는 GitLab.com의 프로덕션 데이터베이스에서 15초로 구성되어 있지만 인덱스를 만드는 데는 15초 이상이 소요될 수 있습니다. add_concurrent_index를 포함한 기존 도우미를 사용하면 필요에 따라 문장 시간 초과를 자동으로 해제합니다. 드물게는 disable_statement_timeout을 사용하여 스스로 제한 시간을 설정해야 할 수도 있습니다.
note
마이그레이션을 실행하기 위해 우리는 PgBouncer를 우회하여 기본 데이터베이스에 직접 연결합니다. 이러한 설정을 제어하기 위해 statement_timeoutlock_wait_timeout과 같은 설정을 사용합니다.

문장 시간 초과 제한을 일시적으로 해제합니다.

마이그레이션 도우미 disable_statement_timeout을 사용하여 문장 시간 초과를 일회성 또는 연결당 0으로 설정할 수 있습니다.

  • 문장이 명시적 트랜잭션 내에서 실행되지 않는다면 CREATE INDEX CONCURRENTLY와 같이, 연결당 옵션을 사용합니다.

  • 명시적 트랜잭션 블록에서 처리할 때와 같이 문장이 명시적 트랜잭션 블록을 지원하는 경우, 트랜잭션별 옵션을 사용해야 합니다.

disable_statement_timeout을 사용하는 경우가 드물지만, 대부분의 마이그레이션 도우미는 이미 필요할 때 내부적으로 사용합니다. 예를 들어 인덱스를 만드는 경우 GitLab.com의 프로덕션 데이터베이스에는 기본 문장 시간 초과로 15초가 설정되어 있으며, add_concurrent_index 도우미는 연결당 문장 시간 초과를 해제하는 데 사용됩니다.

마이그레이션 중에 원시 SQL 문을 작성하는 경우, 매뉴얼으로 disable_statement_timeout을 사용해야 할 수 있습니다. 이를 사용할 경우 데이터베이스 리뷰어 및 유지 관리자와 상의해야 합니다.

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

disable_ddl_transaction!을 사용하여 마이그레이션을 단일 트랜잭션으로 실행하지 않도록 선택할 수 있습니다. 이 메서드는 다른 데이터베이스 시스템에서는 다르게 작동할 수 있습니다. GitLab에서는 PostgreSQL을 전용으로 사용합니다. 항상 disable_ddl_transaction!을 다음과 같이 읽으시길 권장합니다.

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

note
명시적 PostgreSQL 트랜잭션 .transaction (또는 BEGIN; COMMIT;)을 사용하지 않더라도 각 SQL 문은 여전히 트랜잭션으로 실행됩니다. 자세한 내용은 PostgreSQL 트랜잭션에 대한 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 CONCURRENTLY는 반드시 트랜잭션 외부에서 실행되어야 합니다. 따라서 마이그레이션이 하나의 문장 CREATE INDEX CONCURRENTLY만 실행하더라도 disable_ddl_transaction!을 사용해야 합니다. 이것이 add_concurrent_index 도우미를 사용하는 이유이기도 합니다. CREATE INDEX CONCURRENTLY는 예외가 더 많은 규칙입니다.

이와 같은 다양한 이유로 마이그레이션에서 여러 트랜잭션을 실행해야 할 경우에 disable_ddl_transaction!을 사용합니다.

  • 예를 들어 대량의 데이터를 삽입, 업데이트 또는 삭제하기 위해서는 일괄 처리를 해야 합니다. 각 일괄 처리에 연산을 그룹화해야 할 필요가 있으면 일괄 처리를 처리하는 동안 명시적으로 트랜잭션 블록을 열 수 있습니다. 상당한 작업량에 대한 일괄 배경 마이그레이션을 고려하십시오.

마이그레이션 도우미에 필요한 것에 따라 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 서버를 대상으로 할 수도 있습니다. 보통 명시적 트랜잭션 내에서 외부 서비스와 상호작용할 수 없기 때문에 외부 서비스와 상호작용할 수 없습니다.
  • 트랜잭션은 단일 데이터베이스 연결을 위해 사용됩니다. 만약 마이그레이션이 cimain 데이터베이스 모두를 대상으로 한다면 다중 데이터베이스를 위한 마이그레이션을 따르십시오.

네이밍 규칙

데이터베이스 객체(테이블, 인덱스 및 뷰)의 이름은 소문자여아 합니다. 소문자 이름을 사용하면 이름을 따였을 경우 쿼리가 오류를 발생시키지 않도록 합니다.

칼럼 이름은 ActiveRecord의 스키마 규칙에 따라 일관성 있게 유지됩니다.

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

긴 인덱스 이름 줄이기

PostgreSQL은 식별자(열 또는 인덱스 이름과 같은)의 길이를 제한합니다. 열 이름은 일반적으로 문제가 되지 않지만, 인덱스 이름은 일반적으로 더 깁니다. 너무 긴 이름을 줄이는 몇 가지 방법:

  • index_ 대신 i_를 접두어로 사용합니다.
  • 중복되는 접두사를 생략합니다. 예를 들어, index_vulnerability_findings_remediations_on_vulnerability_remediation_idindex_vulnerability_findings_remediations_on_remediation_id로 변경됩니다.
  • 열 대신에 인덱스의 목적을 지정합니다. 예를 들어 index_users_for_unconfirmation_notification과 같이 지정합니다.

마이그레이션 타임스탬프 연령

마이그레이션 파일명의 타임스탬프 부분은 마이그레이션이 실행되는 순서를 결정합니다. GitLab 코드베이스에 마이그레이션이 추가된 시점 및 마이그레이션 자체의 타임스탬프 사이에 대략적인 상관관계를 유지하는 것이 중요합니다.

새로운 마이그레이션의 타임스탬프는 이전의 하드 스탑보다 절대 앞서서는 안됩니다. 때때로 마이그레이션이 통합되기도 하지만, 이전의 하드 스탑보다 이전에 타임스탬프가 지정된 마이그레이션이 추가되면 issue 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, 즉 최신 버전의 마이그레이션 도우미를 자동으로 노출시키는 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 테이블에 대한 전용 잠금이 필요합니다. 테이블이 다른 프로세스에 의해 동시에 액세스되고 수정되기 때문에, 잠금을 획들할 때 시간이 걸릴 수 있습니다. 잠금 요청은 대기열에서 대기하고 있으며 대기열에 들어간 이후에 다른 쿼리까지 블록할 수 있습니다.

PostgreSQL 잠금에 대한 자세한 정보: 명시적 잠금

안정성을 위해, 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을 수행하기 전에 그러한 잠금을 명시적으로 요청하세요. 더 나은 전략은 마이그레이션을 분할하여 한 번에 하나의 잠금만 획득하면 되도록 하는 것입니다.

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

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

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

트랜잭션 불가 마이그레이션과의 사용

disable_ddl_transaction!를 사용하여 트랜잭션을 비활성화할 때만 with_lock_retries 도우미를 사용할 수 있습니다. 이는 주어진 블록을 실행하기 위해 트랜잭션을 엽니다.

특정한 RuboCop 규칙에서는 with_lock_retries 블록에 허용된 메서드만 배치할 수 있도록 합니다.

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 명령어를 유발시킵니다.

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 모듈에 구현되어 있습니다.

최악의 경우, 이 방법은 다음과 같습니다:

  • 40분 이상 실행되는 매우 긴 트랜잭션에서 블록을 최대 50회 실행합니다.
    • 대부분의 시간이 각 반복 후에 사전 구성된 슬립 기간에서 보냅니다.
  • 50번의 재시도 후에 블록이 lock_timeout 없이 실행됩니다. 이는 표준 마이그레이션 호출처럼 작동합니다.
  • 락을 획들할 수 없는 경우, 마이그레이션은 statement timeout 오류로 실패합니다.

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

SQL 수준의 Lock-retry 방법론

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

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

예를 들어, 트랜잭션이 테이블에 행을 삽입하려고 합니다:

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

이 시점에서 트랜잭션 1은 my_notesRowExclusiveLock를 획들었습니다. 트랜잭션 1은 커밋하거나 취소하기 전에 더 많은 문을 실행할 수 있습니다. my_notes를 사용 중인 다른 유사한 동시 트랜잭션이 있을 수 있습니다.

열을 추가하는 트랜잭션이 AccessExclusiveLock을 사용하지 않고 테이블에 열을 추가하는 경우:

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

트랜잭션 2는 AccessExclusiveLock을 획들할 수 없기 때문에 트랜잭션 2는 블록됩니다. 왜냐하면 트랜잭션 1이 여전히 실행 중이며 my_notesRowExclusiveLock을 보유하고 있기 때문입니다.

이로 인해 일반적으로 트랜잭션 1과 충돌하지 않을 수 있던 테이블에서 동시에 읽기 및 쓰기하는 트랜잭션들도 즉시 실행되지 않고 테이블에서 발생하는 충돌 락 요청으로 인해 차단됩니다.

with_lock_retries를 사용하는 경우, 트랜잭션 2는 지정된 기간 내에 락을 획들지 못한 후 신속하게 타임아웃됩니다. 그 후 다시 시도합니다:

-- with lock timeout 버전의 트랜잭션 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

인덱스가 사용되지 않는지 확인하려면 Thanos를 사용할 수 있습니다.

sum by (type)(rate(pg_stat_user_indexes_idx_scan{env="gprd", indexrelname="INSERT INDEX NAME HERE"}[30d]))
note
: 인덱스가 존재하는지 미리 확인할 필요는 없지만 제거되는 인덱스의 이름을 명시해야 합니다. 이 작업은 remove_index 또는 remove_concurrent_index 양식의 옵션으로 이름을 전달하거나 remove_concurrent_index_by_name 메소드를 사용하여 수행할 수 있습니다. 이름을 명시적으로 지정하는 것은 올바른 인덱스가 제거되도록 하는 데 중요합니다.

인덱스 비활성화

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

인덱스 추가

인덱스를 추가하기 전에 해당 인덱스가 필요한지 여부를 고려하세요. 데이터베이스 인덱스 추가 가이드에서 인덱스가 필요한지 여부를 결정하는 데 도움이 되는 자세한 내용을 확인할 수 있습니다.

인덱스의 존재 여부 테스트

마이그레이션이 인덱스의 부재 또는 존재에 따라 조건부 논리를 필요로 하는 경우 해당 인덱스의 존재 여부를 이름을 사용하여 테스트해야 합니다. 이렇게 함으로써 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

비어 있거나 1,000 레코드 미만과 같이 작은 테이블의 경우 disable_ddl_transaction!을 필요로하지 않는 단일 트랜잭션 마이그레이션에서 add_reference를 사용하는 것이 좋습니다.

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

높은 트래픽 테이블을 참조하는 새로운 또는 빈 테이블이 있다면 단일 트랜잭션 마이그레이션에서 disable_ddl_transaction!을 필요로하지 않는 add_reference를 사용하는 것이 좋습니다.

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

NOT NULL 제약 조건

  • 도입되었습니다 GitLab 13.0.

더 많은 정보는 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 테이블의 모든 기존 레코드를 다시 작성하는 것은 아닙니다. 기본값이 있는 새로운 열을 만들 때에만 모든 레코드가 다시 작성됩니다.

note
PostgreSQL 11.0에서 새로운 기본값이 있는 열이 추가될 때 테이블을 다시 작성할 필요가 없게되어 ALTER TABLE ADD COLUMN with a non-null default가 빨라졌습니다.

위에서 설명한 이유로 disable_ddl_transaction!을 필요로하지 않는 단일 트랜잭션 마이그레이션에서 change_column_default을 안전하게 사용할 수 있습니다.

기존 열 업데이트

기존 열을 특정 값으로 업데이트하려면 update_column_in_batches를 사용할 수 있습니다. 이를 통해 업데이트가 배치로 분할되어 단일 명령문에서 너무 많은 행을 동시에 업데이트하지 않게 됩니다.

이 명령은 some_column'hello'인 경우, projects 테이블의 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의 경우, 테이블이 크더라도 괜찮을 수 있지만, 테이블의 행 중 작은 부분만 업데이트하는 경우에만 글로벌 스테이징 환경에서 유효성을 검사하지 않거나 다른 사람에게 사전에 확인을 요청하지 않도록 주의하세요.

외부 키 제약 조건 제거

외부 키 제약 조건을 제거할 때, 외부 키와 관련된 두 테이블에 대해 잠금을 획들해야 합니다. 쓰기 패턴이 많이 있는 테이블의 경우, 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 문은 일반적으로 동시 트래픽을 지연시킬 가능성이 있으며, 최종적으로 문 실행 시간 제한 오류로 실패할 수 있습니다.

  • 테이블에 레코드가 없는 경우 (기능이 사용되지 않았을 때):

    • 마이그레이션에서 drop_table 메서드를 사용하세요.
def change
  drop_table :my_table
end
  • 테이블에 레코드가 있지만 외부 키가 없는 경우:
    • 테이블과 관련된 응용 프로그램 코드(모델, 컨트롤러 및 서비스 등)를 제거하세요.
    • 배포 후 마이그레이션에서 drop_table을 사용하세요.

코드가 사용되지 않음을 확신한다면 이 모든 것을 단일 마이그레이션에 넣을 수 있습니다. 약간의 위험을 줄이고 싶다면, 애플리케이션 변경이 Merge된 후에 두 번째 Merge Request으로 마이그레이션을 포함하는 것을 고려해 보세요. 이 접근 방식은 롤백할 수 있는 기회를 제공합니다.

def up
  drop_table :my_table
end

def down
  # create_table ...
end
  • 테이블에 외부 키가 있는 경우:
    • 테이블과 관련된 응용 프로그램 코드(모델, 컨트롤러 및 서비스 등)를 제거하세요.
    • 배포 후 마이그레이션에서 with_lock_retries 도우미 메서드를 사용하여 외부 키를 제거하세요. 다른 후속 배포 마이그레이션에서 drop_table을 사용하세요.

코드가 사용되지 않음을 확신한다면 이 모든 것을 단일 마이그레이션에 넣을 수 있습니다. 약간의 위험을 줄이고 싶다면, 애플리케이션 변경이 Merge된 후에 두 번째 Merge Request으로 마이그레이션을 포함하는 것을 고려해 보세요. 이 접근 방식은 롤백할 수 있는 기회를 제공합니다.

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
note
외부 키가 있는 열에 대해 add_sequence를 사용하는 것은 피해야 합니다. 이러한 열에 시퀀스를 추가하는 것은 down 메서드에서만 허용됩니다 (이전 스키마 상태를 복원).

테이블 Truncate

  • 소개: GitLab 15.11에서 도입되었습니다.

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

이 작업은 다음과 같이 작동합니다.

  • Truncate할 테이블의 gitlab_schema를 찾습니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있는 경우, TRUNCATE 문을 실행합니다.
  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있지 않은 경우, 아무 작업도 수행하지 않습니다.

주요 키 교체

  • 소개: GitLab 15.5에서 도입되었습니다.

기본 키를 바꾸는 것은 파티션 키를 기본 키에 포함해야 하기 때문에 필요합니다.

데이터베이스 팀에서 제공하는 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
새 인덱스를 도입하기 전에 별도의 마이그레이션으로 인덱스를 도입해야 합니다. ## Integer 열 유형

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

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

  • 소개: GitLab 13.0에서 도입되었습니다.

자세한 정보는 텍스트 데이터 유형 스타일 가이드에서 확인하세요.

타임스탬프 열 유형

기본적으로 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 accesss를 위해 또는 :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의 테이블 구조 정보가 최신 상태인지 확인합니다.
    # ... ...
  end
end

이렇게 할 때 모델의 테이블 이름을 명시적으로 설정하여 클래스 이름이나 네임스페이스에서 파생되지 않도록 주의하세요.

migrations에서 애플리케이션 코드 사용 시 주의사항을 확인하세요.

기존 데이터 수정

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

우리는 새로운 도우미 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를 확인하세요.

고트래픽 테이블

현재의 고트래픽 테이블 디렉터리은 다음과 같습니다.

어떤 테이블이 고트래픽인지 판별하는 것은 어려울 수 있습니다. Self-managed 인스턴스는 GitLab.com을 기반으로 하는 가정만으로는 충분하지 않을 수 있으므로 다른 사용 패턴을 가질 수 있습니다.

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

현재의 고트래픽 테이블과 비교하여 고읽기 작업을 수행하는 테이블은 좋은 후보일 수 있습니다.

일반적인 규칙으로, 전적으로 GitLab.com의 분석이나 보고를 위한 열을 고트래픽 테이블에 추가하는 것을 권장하지 않습니다. 이는 직접적인 기능 가치를 제공하지 않으면서 Self-managed 인스턴스 전체에 부정적인 성능 영향을 미칠 수 있습니다.

마일스톤

GitLab 16.6부터 모든 새로운 마이그레이션이 다음 구문을 사용하여 마일스톤을 명시해야 합니다.

class AddFooToBar < Gitlab::Database::Migration[2.2]
  milestone '16.6'
  
  def change
    # 여기에 마이그레이션 작성
  end
end

마이그레이션에 올바른 마일스톤을 추가하면 GitLab 마이너 버전에 따라 마이그레이션을 논리적으로 분할할 수 있습니다. 이렇게 하면:

  • 업그레이드 프로세스가 간단해집니다.
  • 마이그레이션 순서 문제를 완화시킵니다.

Autovacuum 랩어라운드 보호

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

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

만일 마이그레이션이 충돌하는 잠금을 갖고 있지 않다면, vacuum 확인은 완전한 테이블 이름을 사용하지 않음으로써 생략될 수 있습니다, 예를 들어 create_async_index_on_job_artifacts.