InfoGrab DocsInfoGrab Docs

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

요약

또한 규모에 관계없이 업그레이드를 위해 서버를 오프라인으로 전환해야 하는 상황은 대부분의 조직에게 큰 부담입니다. 마이그레이션은 GitLab 설치를 절대 오프라인으로 전환하도록 요구해서는 안 됩니다. 마이그레이션을 작성할 때는 데이터베이스에 오래된 데이터나 불일치가 있을 수 있다는 점도 고려하고 이를 방어적으로 처리하세요.


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

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

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

마이그레이션은 GitLab 설치를 절대 오프라인으로 전환하도록 요구해서는 됩니다. 마이그레이션은 항상 다운타임을 피하는 방식으로 작성되어야 합니다. 과거에는 DOWNTIME 상수를 설정하여 다운타임을 허용하는 마이그레이션을 정의하는 프로세스가 있었습니다. 오래된 마이그레이션을 살펴보면 이를 확인할 수 있습니다. 이 프로세스는 4년 동안 유지되었지만 실제로 사용된 적은 없으며, 이를 통해 다운타임을 피하기 위해 마이그레이션을 항상 다르게 작성하는 방법을 찾아낼 수 있다는 것을 배웠습니다.

마이그레이션을 작성할 때는 데이터베이스에 오래된 데이터나 불일치가 있을 수 있다는 점도 고려하고 이를 방어적으로 처리하세요. 데이터베이스 상태에 대해 가능한 한 가정을 적게 세우도록 노력하세요.

미래 버전에서 변경될 수 있으므로 GitLab 특정 코드에 의존하지 마세요. 필요한 경우 GitLab 코드를 마이그레이션에 복사하여 앞으로도 호환 가능하게 만드세요.

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

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

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

일반 스키마 마이그레이션. 이는 새 애플리케이션 코드가 배포되기 이전에 실행되는 db/migrate의 전통적인 Rails 마이그레이션입니다 (GitLab.com의 경우 Canary가 배포되기 전). 즉, 배포를 불필요하게 지연시키지 않도록 몇 분 이내로 비교적 빠르게 실행되어야 합니다.

예외적으로 시간이 더 걸리지만 애플리케이션이 올바르게 동작하는 데 절대적으로 중요한 마이그레이션이 있을 수 있습니다. 예를 들어 고유한 튜플을 강제하는 인덱스나 애플리케이션의 중요한 부분에서 쿼리 성능에 필요한 인덱스가 있을 수 있습니다. 그러나 마이그레이션이 허용할 수 없을 정도로 느린 경우, 피처 플래그로 기능을 보호하고 대신 배포 후 마이그레이션을 수행하는 것이 더 나은 방법일 수 있습니다. 마이그레이션이 완료된 후 기능을 활성화하면 됩니다.

새 모델을 추가하는 마이그레이션도 이 일반 스키마 마이그레이션의 일부입니다. 차이점은 마이그레이션 생성에 사용하는 Rails 명령어와 추가로 생성되는 파일(모델용 파일 하나, 모델의 스펙 파일 하나)뿐입니다.

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

사용하지 않는 칼럼 제거와 같은 정리 작업.

  • 트래픽이 많은 테이블에 중요하지 않은 인덱스 추가.

  • 생성하는 데 오랜 시간이 걸리는 중요하지 않은 인덱스 추가.

이 마이그레이션은 애플리케이션 동작에 중요한 스키마 변경에는 사용해서는 안 됩니다. 배포 후 마이그레이션에서 이러한 스키마 변경을 수행하면 과거에 문제가 발생한 사례가 있습니다. 예를 들어 이 이슈가 있습니다. 항상 일반 스키마 마이그레이션이어야 하며 배포 후 마이그레이션에서 실행해서는 안 되는 변경 사항은 다음과 같습니다:

  • 새 테이블 생성, 예: create_table.

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

    배포 후 마이그레이션은 PDM으로 약칭되는 경우가 많습니다.

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

다음 다이어그램을 참고하여 결정을 내리되, 이는 단순한 도구일 뿐이며 최종 결과는 항상 구체적인 변경 사항에 따라 달라진다는 점을 염두에 두세요:

graph LR A{Schema
changed?} A -->|Yes| C{Critical to
speed or
behavior?} A -->|No| D{Is it fast?}

C -->|Yes| H{Is it fast?}
C -->|No| F[Post-deploy migration]

H -->|Yes| E[Regular migration]
H -->|No| I[Post-deploy migration<br/>&#43; feature flag]

D -->|Yes| F[Post-deploy migration]
D -->|No| G[Background migration]

데이터베이스 인덱스를 추가할 때 사용할 마이그레이션 유형 선택에 대해서는 사용할 마이그레이션 유형을 참고하세요.

마이그레이션 소요 시간#

일반적으로, 단일 배포에 대한 모든 마이그레이션은 GitLab.com 기준으로 1시간을 초과해서는 안 됩니다. 다음 가이드라인은 엄격한 규칙이 아니며, 마이그레이션 소요 시간을 최소화하기 위해 추정된 기준입니다.

모든 소요 시간은 GitLab.com을 기준으로 측정해야 함을 유의하세요.

데이터베이스 마이그레이션 파이프라인의 결과에는 마이그레이션에 대한 타이밍 정보가 포함되어 있습니다.

동시 작업 및 백그라운드 마이그레이션을 포함한 쿼리 수준 타이밍 제한에 대해서는 쿼리 성능 가이드라인을 참고하세요.

마이그레이션 유형 권장 소요 시간 비고
일반 마이그레이션 3분 이하 애플리케이션 기능 또는 성능이 심각하게 저하될 수 있고 지연할 수 없는 변경 사항은 유효한 예외입니다.
배포 후 마이그레이션 10분 이하 스키마 변경은 백그라운드 마이그레이션에서 수행할 수 없으므로 유효한 예외입니다. 인덱스 생성과 같은 동시 작업은 별도로 20분 제한이 있습니다.
백그라운드 마이그레이션 10분 초과 대용량 테이블에 적합하므로 정확한 타이밍 가이드라인을 설정할 수 없지만, 단일 쿼리는 콜드 캐시 기준 실행 시간이 1초 미만이어야 합니다.

db:gitlabcom-database-testing 파이프라인이 인덱스 생성에 20분 이상이 소요된다고 보고하는 경우, 인덱스를 비동기적으로 생성하세요. 테스트 파이프라인은 실제 GitLab.com 실행 시간을 과소평가할 수 있는 데이터베이스 클론에서 실행되므로, 이 임계값은 의도적으로 보수적으로 설정되어 있습니다.

대형 테이블 제한 사항#

크기 임계값을 초과하는 테이블의 경우, 새 칼럼 또는 인덱스를 추가하기 전에 대형 테이블 제한 사항을 먼저 확인하세요.

타깃 데이터베이스 결정#

GitLab은 mainci 두 가지 서로 다른 Postgres 데이터베이스에 연결합니다. 이 분리로 인해 마이그레이션이 두 데이터베이스 중 하나 또는 모두에서 실행될 수 있습니다.

추가하는 마이그레이션이 이를 고려해야 하는지 또는 어떻게 고려해야 하는지 이해하려면 여러 데이터베이스에 대한 마이그레이션을 참고하세요.

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

마이그레이션을 생성하려면 다음 Rails 제너레이터를 사용할 수 있습니다.

bundle exec rails g migration migration_name_here

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

새 모델 추가를 위한 일반 스키마 마이그레이션#

새 모델을 생성하려면 다음 Rails 제너레이터를 사용할 수 있습니다.

bundle exec rails g model model_name_here

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

  • db/migrate에 마이그레이션 파일

  • app/models에 모델 파일

  • spec/models에 스펙 파일

스키마 변경#

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

[인덱스를 비동기적으로 생성하려면 두 개의 머지 리퀘스트가 필요합니다.](/19.1/development/database/adding_database_indexes/#add-a-migration-to-create-the-index-synchronously)

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

GDK의 로컬 데이터베이스가 main의 스키마에서 벗어날 경우, 스키마 변경 사항을 Git에 깔끔하게 커밋하기 어려울 수 있습니다. 이 경우 scripts/regenerate-schema 스크립트를 사용하여 추가하려는 마이그레이션에 대해 깔끔한 db/structure.sql을 재생성할 수 있습니다. 이 스크립트는 db/migrate 또는 db/post_migrate에 있는 모든 마이그레이션을 적용하므로, 스키마에 커밋하지 않으려는 마이그레이션이 있다면 이름을 변경하거나 삭제하세요. 브랜치가 기본 Git 브랜치를 타깃으로 하지 않는 경우, TARGET 환경 변수를 설정할 수 있습니다.

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

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

scripts/regenerate-schema 스크립트는 추가적인 차이를 만들 수 있습니다. 이 경우, <migration ID>가 마이그레이션 파일의 DATETIME 부분인 수동 절차를 사용하세요.

# Rebase against master
git rebase master

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

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

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

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

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

마이그레이션이 처음 실행되면, 마이그레이션의 타임스탬프에서 생성된 SHA256을 포함하는 새로운 마이그레이션 체크섬 파일db/schema_migrations에 생성됩니다. 이 새 파일의 이름은 마이그레이션 파일명의 타임스탬프 부분과 동일합니다. 예를 들면 db/schema_migrations/20241021120146과 같습니다. 이 파일의 내용은 타임스탬프 부분의 SHA256이며, 예를 들면 다음과 같습니다:

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

SHA256은 파일에 고유한 콘텐츠를 추가하여 Git의 이름 변경 감지가 이를 별개의 파일로 인식하도록 합니다.

마이그레이션 체크섬 파일은 마이그레이션이 성공적으로 실행되었으며 그 결과가 db/structure.sql에 기록되었음을 나타냅니다. 이 파일이 존재하면 동일한 마이그레이션이 두 번 실행되는 것을 방지하므로, 새 마이그레이션을 추가하는 머지 리퀘스트에 반드시 이 파일을 포함시켜야 합니다.

db/schema_migrations 디렉터리에 대한 자세한 내용은 개발 변경 사항: structure.sql 외부의 데이터베이스 스키마 버전 처리를 참조하십시오.

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

  • 새 마이그레이션이 생성되면 rake db:migrate를 실행하여 마이그레이션을 실행하고 해당 db/schema_migration/<timestamp> 체크섬 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.

  • 마이그레이션이 삭제되면 해당 db/schema_migration/<timestamp> 체크섬 파일을 삭제합니다.

  • 마이그레이션의 타임스탬프 부분이 변경되면 해당 db/schema_migration/<timestamp> 체크섬 파일을 삭제하고 rake db:migrate를 실행하여 새 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.

  • 마이그레이션의 내용이 변경된 경우, db/schema_migration/<timestamp> 체크섬 파일을 변경할 필요가 없습니다.

다운타임 방지#

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

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

가역성#

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

GitLab 프로덕션 환경에서는 문제가 발생하면 `db:rollback`을 사용하여 마이그레이션을 롤백하는 대신 롤포워드 전략을 사용합니다.

GitLab Self-Managed의 경우, 업그레이드 프로세스가 시작되기 전에 생성된 백업을 복원하도록 사용자에게 권장합니다. down 메서드는 주로 개발 환경에서 사용되며, 예를 들어 개발자가 커밋이나 브랜치 간 전환 시 로컬의 structure.sql 파일과 데이터베이스가 일관된 상태인지 확인하고자 할 때 사용됩니다.

마이그레이션에 마이그레이션의 가역성이 어떻게 테스트되었는지 설명하는 주석을 추가하십시오.

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

def down
  # no-op

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

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

원자성과 트랜잭션#

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

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

  • 모든 마이그레이션을 하나의 단일 트랜잭션 마이그레이션에 넣습니다.

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

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

서브트랜잭션은 일반적으로 [허용되지 않습니다](https://about.gitlab.com/blog/why-we-spent-the-last-month-eliminating-postgresql-subtransactions/).

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

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

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

단일 트랜잭션 마이그레이션이 완료되는 데 시간이 오래 걸리는 경우 여러 가지 옵션이 있습니다. 모든 경우에 마이그레이션 소요 시간에 따라 적절한 마이그레이션 유형을 선택하는 것을 잊지 마십시오.

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

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

구문 및 잠금 타임아웃 설정을 조정한 후 단일 트랜잭션 마이그레이션을 계속 사용하세요. 무거운 워크로드가 반드시 트랜잭션의 보장을 사용해야 한다면, 마이그레이션이 타임아웃 제한에 걸리지 않고 실행될 수 있는지 확인해야 합니다. 동일한 권고 사항이 단일 트랜잭션 마이그레이션과 개별 트랜잭션 모두에 적용됩니다.

구문 타임아웃: 구문 타임아웃은 GitLab.com의 프로덕션 데이터베이스에서 15s로 설정되어 있지만, 인덱스를 생성하는 데는 15초 이상이 걸리는 경우가 많습니다. add_concurrent_index를 포함한 기존 헬퍼를 사용하면, 필요 시 구문 타임아웃이 자동으로 비활성화됩니다. 드물게 disable_statement_timeout을 사용하여 타임아웃 제한을 직접 설정해야 할 수도 있습니다.

마이그레이션을 실행하기 위해 PgBouncer를 우회하여 프라이머리 데이터베이스에 직접 연결하며,

statement_timeoutlock_wait_timeout과 같은 설정을 제어합니다.

구문 타임아웃 제한 일시 비활성화#

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

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

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

disable_statement_timeout 사용은 거의 필요하지 않습니다. 대부분의 마이그레이션 헬퍼가 필요 시 내부적으로 이미 사용하고 있기 때문입니다. 예를 들어, 인덱스 생성은 일반적으로 15초 이상 걸리는데, 이것이 GitLab.com의 프로덕션 데이터베이스에 설정된 기본 구문 타임아웃입니다. 헬퍼 add_concurrent_index는 연결별 구문 타임아웃을 비활성화하기 위해 disable_statement_timeout에 전달된 블록 내에서 인덱스를 생성합니다.

마이그레이션에서 원시 SQL 구문을 작성하는 경우, disable_statement_timeout을 수동으로 사용해야 할 수도 있습니다. 이 경우 데이터베이스 리뷰어 및 메인테이너와 상담하세요.

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

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

“이 마이그레이션을 단일 PostgreSQL 트랜잭션으로 실행하지 않습니다. PostgreSQL 트랜잭션은 필요할 경우에만 열겠습니다.”

명시적 PostgreSQL 트랜잭션 `.transaction`(또는 `BEGIN; COMMIT;`)을 사용하지 않더라도,

모든 SQL 구문은 여전히 트랜잭션으로 실행됩니다. 트랜잭션에 대한 PostgreSQL 문서를 참조하세요.

GitLab에서는 disable_ddl_transaction!을 사용하는 마이그레이션을 비트랜잭션 마이그레이션이라고 부르는 경우가 있었습니다. 이는 단지 해당 마이그레이션이 단일 트랜잭션으로 실행되지 않았다는 것을 의미합니다.

disable_ddl_transaction!은 언제 사용해야 할까요? 대부분의 경우, 기존 RuboCop 규칙이나 마이그레이션 헬퍼가 disable_ddl_transaction!을 사용해야 하는지 여부를 감지할 수 있습니다. 사용 여부가 불분명한 경우 disable_ddl_transaction!을 건너뛰고, RuboCop 규칙과 데이터베이스 리뷰의 안내를 따르세요.

PostgreSQL이 명시적 트랜잭션 외부에서 작업을 실행하도록 요구하는 경우 disable_ddl_transaction!을 사용하세요.

  • 이러한 작업의 가장 대표적인 예는 CREATE INDEX CONCURRENTLY 명령입니다. PostgreSQL은 블로킹 버전(CREATE INDEX)을 트랜잭션 내에서 실행할 수 있도록 허용합니다. CREATE INDEX와 달리, CREATE INDEX CONCURRENTLY는 트랜잭션 외부에서 실행해야 합니다. 따라서 마이그레이션이 CREATE INDEX CONCURRENTLY 구문 하나만 실행하더라도, disable_ddl_transaction!을 비활성화해야 합니다. 헬퍼 add_concurrent_index를 사용하려면 disable_ddl_transaction!이 필요한 것도 이 때문입니다. CREATE INDEX CONCURRENTLY는 일반적인 규칙보다는 예외에 해당합니다.

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

  • 예를 들어, 대량의 데이터를 삽입, 업데이트, 또는 삭제(DML)하는 경우, 배치로 수행해야 합니다. 각 배치에 대해 작업을 그룹화해야 하는 경우, 배치를 처리할 때 명시적으로 트랜잭션 블록을 열 수 있습니다. 합리적으로 큰 워크로드에는 배치 백그라운드 마이그레이션을 사용하는 것을 고려하세요.

마이그레이션 헬퍼가 필요로 하는 경우 disable_ddl_transaction!을 사용하세요. 다양한 마이그레이션 헬퍼는 트랜잭션을 언제, 어떻게 열지에 대한 정밀한 제어가 필요하기 때문에 disable_ddl_transaction!과 함께 실행해야 합니다.

  • 외래 키는 CREATE INDEX CONCURRENTLY와 달리 트랜잭션 내에서 추가할 수 있습니다. 그러나 PostgreSQL은 CREATE INDEX CONCURRENTLY와 유사한 옵션을 제공하지 않습니다. 헬퍼 add_concurrent_foreign_key는 외래 키를 추가하고 유효성을 검사하는 동안 잠금을 최소화하는 방식으로 소스 테이블과 타깃 테이블을 잠그기 위해 자체 트랜잭션을 엽니다.

  • 앞서 권고한 대로, 확실하지 않은 경우 disable_ddl_transaction!을 건너뛰고 RuboCop 검사를 위반하는지 확인하세요.

마이그레이션이 실제로 PostgreSQL 데이터베이스에 접근하지 않거나 여러 PostgreSQL 데이터베이스에 접근하는 경우 disable_ddl_transaction!을 사용하세요.

  • 예를 들어, 마이그레이션이 Redis 서버를 타깃으로 할 수 있습니다. 원칙적으로 PostgreSQL 트랜잭션 내에서 외부 서비스와 상호 작용할 수 없습니다.

  • 트랜잭션은 단일 데이터베이스 연결에 사용됩니다. 마이그레이션이 ci 데이터베이스와 main 데이터베이스 등 여러 데이터베이스를 타깃으로 하는 경우, 여러 데이터베이스를 위한 마이그레이션을 따르세요.

명명 규칙#

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

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

커스텀 인덱스 및 제약 조건 이름은 제약 조건 명명 규칙 가이드라인을 따라야 합니다.

긴 인덱스 이름 줄이기#

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

  • index_ 대신 i_ 접두사를 사용합니다.

  • 중복되는 접두사를 생략합니다. 예를 들어, index_vulnerability_findings_remediations_on_vulnerability_remediation_idindex_vulnerability_findings_remediations_on_remediation_id로 줄일 수 있습니다.

  • 칼럼 이름 대신 index_users_for_unconfirmation_notification과 같이 인덱스의 목적을 명시합니다.

마이그레이션 타임스탬프 시기#

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

  • 마이그레이션이 GitLab 코드베이스에 추가되는 시점.

  • 마이그레이션 자체의 타임스탬프.

새 마이그레이션의 타임스탬프는 이전 필수 업그레이드 중단점보다 앞서서는 안 됩니다. 마이그레이션은 때때로 스쿼시(squash)되는데, 이전 필수 중단점 이전에 해당하는 타임스탬프를 가진 마이그레이션이 추가되면 이슈 408304에서 발생한 것과 같은 문제가 생길 수 있습니다.

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

모범 사례#

위 내용은 반드시 지켜야 할 규칙으로 간주해야 하지만, 마이그레이션 타임스탬프를 마지막 필수 중단점 이후 경과 시간과 무관하게 머지될 것으로 예상되는 날짜 기준 3주 이내로 유지하는 것이 모범 사례입니다.

마이그레이션 타임스탬프를 업데이트하려면:

cimain 데이터베이스에 대해 마이그레이션을 다운(migrate down)합니다:

rake db:migrate:down:main VERSION=<timestamp>
rake db:migrate:down:ci VERSION=<timestamp>

마이그레이션 파일을 삭제합니다.

마이그레이션 스타일 가이드에 따라 마이그레이션을 다시 생성합니다.

또는 다음 스크립트를 사용하여 모든 마이그레이션 타임스탬프를 갱신할 수 있습니다:

scripts/refresh-migrations-timestamps

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

  • 모든 마이그레이션 타임스탬프를 현재 시각으로 업데이트합니다.

  • 마이그레이션의 상대적 순서를 유지합니다.

  • 파일명과 마이그레이션 클래스 내부의 타임스탬프를 모두 업데이트합니다.

  • 일반 마이그레이션과 배포 후(post-deployment) 마이그레이션 모두 처리합니다.

    마이그레이션이 오랜 시간(> 3주) 리뷰 중이었거나 오래된 마이그레이션 브랜치를 리베이스할 때, 머지 전에 이 스크립트를 실행하세요.

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

데이터베이스 마이그레이션의 많은 공통 패턴에 대해 다양한 헬퍼 메서드를 사용할 수 있습니다. 이러한 헬퍼는 Gitlab::Database::MigrationHelpers 및 관련 모듈에서 찾을 수 있습니다.

헬퍼의 동작을 시간이 지남에 따라 변경할 수 있도록, 마이그레이션 헬퍼에 대한 버전 관리 체계를 구현합니다. 이를 통해 이미 존재하는 마이그레이션에 대해서는 헬퍼의 동작을 유지하면서도, 새 마이그레이션에 대해서는 동작을 변경할 수 있습니다.

이를 위해 모든 데이터베이스 마이그레이션은 “버전이 지정된(versioned)” 클래스인 Gitlab::Database::Migration을 상속해야 합니다. 새 마이그레이션에는 최신 버전의 마이그레이션 헬퍼를 사용하기 위해 최신 버전을 사용해야 하며(Gitlab::Database::Migration::MIGRATION_CLASSES에서 조회 가능), 이를 통해 최신 버전의 마이그레이션 헬퍼를 사용할 수 있습니다.

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

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

마이그레이션에 Gitlab::Database::MigrationHelpers를 직접 포함(include)하지 마세요. 대신 최신 버전의 Gitlab::Database::Migration을 사용하면 최신 버전의 마이그레이션 헬퍼가 자동으로 제공됩니다.

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

데이터베이스 스키마를 변경할 때, DDL(Data Definition Language) 구문을 실행하기 위해 헬퍼 메서드를 사용합니다. 경우에 따라 이러한 DDL 구문은 특정 데이터베이스 락이 필요합니다.

예시:

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

이 마이그레이션을 실행하려면 users 테이블에 대한 배타적 락(exclusive lock)이 필요합니다. 다른 프로세스가 테이블에 동시에 접근하거나 수정하는 경우, 락을 획득하는 데 시간이 걸릴 수 있습니다. 락 요청은 대기열에서 기다리며, 일단 대기열에 들어가면 users 테이블에 대한 다른 쿼리도 차단될 수 있습니다.

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

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

이 문제는 애플리케이션 업그레이드 프로세스 실패와 심지어 애플리케이션 안정성 문제를 야기할 수 있습니다. 테이블이 짧은 시간 동안 접근 불가 상태가 될 수 있기 때문입니다.

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

non_transactional 마이그레이션 작업 시, with_lock_retries 메서드를 사용하면 마이그레이션 내에서 실행되는 코드 블록에 대한 잠금 획득 재시도 및 타임아웃 구성을 명시적으로 제어할 수 있습니다.

트랜잭션 마이그레이션#

일반 마이그레이션은 트랜잭션 내에서 전체 마이그레이션을 실행합니다. lock-retry 메커니즘은 기본적으로 활성화되어 있습니다(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 잠금이 필요하며, 동일한 트랜잭션에서 여러 테이블을 잠그는 것은 피해야 합니다.

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

  • 외래 키 없이 테이블 생성 (인덱스 포함).

  • 첫 번째 테이블에 외래 키 추가.

  • 두 번째 테이블에 외래 키 추가.

테이블 생성:

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 retry가 내장되어 있습니다.

disable_ddl_transaction!

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

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

users에 외래 키 추가:

disable_ddl_transaction!

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

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

2단계 외래 키 유효성 검사로 잠금 경합 최소화#

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

  • 첫 번째 마이그레이션: validate: false로 외래 키를 추가하여 배포 중 쓰기 차단 방지

  • 두 번째 마이그레이션: prepare_async_foreign_key_validation을 사용하여 외래 키를 비동기적으로 유효성 검사

  • 세 번째 마이그레이션: 유효성 검사 완료 후 부모 외래 키 추가 (파티션된 테이블의 경우)

add_concurrent_foreign_key는 이미 내부적으로 2단계 유효성 검사(유효성 검사 없이 추가 후 유효성 검사)를 수행하지만, 이러한 단계를 서로 다른 마이그레이션으로 분리하면 유효성 검사가 배포 윈도우 외부에서 수행될 수 있어 배포 시간과 위험을 줄이는 배포 유연성을 제공합니다.

이 방식은 이 사례처럼 프로덕션 인시던트에서 볼 수 있듯이, 중요한 배포 윈도우 중 외래 키 유효성 검사가 예상보다 오래 걸릴 때 잠금 경합으로 인한 배포 차단을 최소화합니다.

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

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

커스텀 RuboCop 규칙에 의해 잠금 재시도 블록 내에서는 허용된 메서드만 사용할 수 있습니다.

disable_ddl_transaction!

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

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

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

disable_ddl_transaction!

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

헬퍼 메서드 사용 시기#

with_lock_retries 헬퍼 메서드는 실행이 이미 열린 트랜잭션 내부에 있지 않을 때(PostgreSQL 서브트랜잭션 사용은 권장되지 않음)에만 사용할 수 있습니다. 표준 Rails 마이그레이션 헬퍼 메서드와 함께 사용할 수 있습니다. 동일한 테이블에서 실행되는 경우 마이그레이션 헬퍼를 두 개 이상 호출해도 문제가 없습니다.

데이터베이스 마이그레이션이 트래픽이 많은 테이블 중 하나와 관련된 경우 with_lock_retries 헬퍼 메서드 사용이 권장됩니다.

변경 예시:

  • add_foreign_key / remove_foreign_key

  • add_column / remove_column

  • change_column_default

  • create_table / drop_table

with_lock_retries 메서드는 change 메서드 내에서 사용할 수 없으며, 마이그레이션을 되돌릴 수 있게 하려면 updown 메서드를 수동으로 정의해야 합니다.

헬퍼 메서드의 동작 방식#

  • 50회 반복합니다.

  • 각 반복마다 사전 설정된 lock_timeout을 설정합니다.

  • 주어진 블록 실행을 시도합니다. (remove_column).

  • LockWaitTimeout 오류가 발생하면 사전 설정된 sleep_time 동안 대기한 후 블록을 재시도합니다.

  • 오류가 발생하지 않으면 현재 반복에서 블록이 성공적으로 실행된 것입니다.

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

최악의 경우, 이 메서드는:

  • 40분에 걸쳐 최대 50회 블록을 실행합니다.

대부분의 시간은 각 반복 후 사전 설정된 대기 시간에 소비됩니다.

  • 50번째 재시도 후에는 표준 마이그레이션 호출과 같이 lock_timeout 없이 블록이 실행됩니다.

  • 잠금을 획득할 수 없으면 마이그레이션이 statement timeout 오류와 함께 실패합니다.

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

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

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

칼럼을 추가하기 위해 테이블을 변경할 때,

AccessExclusiveLock은 대부분의 잠금 유형과 충돌하며, 해당 테이블에서 이 잠금을 획득해야 합니다. 대상 테이블이 매우 바쁜 테이블인 경우, 칼럼을 추가하는 트랜잭션이 제때 AccessExclusiveLock을 획득하지 못할 수 있습니다.

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

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

이 시점에서 Transaction 1은 my_notes에 대한 RowExclusiveLock을 획득합니다. Transaction 1은 커밋 또는 중단 전에 더 많은 구문을 실행할 수 있습니다. my_notes를 참조하는 다른 유사한 동시 트랜잭션이 있을 수도 있습니다.

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

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

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

더 심각한 부작용은 일반적으로 Transaction 1과 충돌하지 않을 트랜잭션들까지 차단된다는 점입니다. Transaction 2가 AccessExclusiveLock 획득을 위해 대기 중이기 때문입니다. 정상적인 상황에서는 다른 트랜잭션이 Transaction 1과 동시에 my_notes 테이블에서 읽기 및 쓰기를 시도하더라도, 읽기·쓰기에 필요한 잠금이 Transaction 1이 보유한 RowExclusiveLock과 충돌하지 않으므로 해당 트랜잭션은 통과됩니다. 그러나 AccessExclusiveLock 획득 요청이 대기열에 추가되면, 테이블의 충돌하는 잠금에 대한 이후 요청들은 Transaction 1과 함께 동시에 실행될 수 있음에도 불구하고 차단됩니다.

with_lock_retries를 사용하면, Transaction 2는 지정된 시간 내에 잠금을 획득하지 못할 경우 빠르게 타임아웃되어 다른 트랜잭션이 진행될 수 있습니다:

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

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

SET LOCAL은 파라미터(lock_timeout) 변경을 트랜잭션 범위로 한정합니다.

인덱스 제거#

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

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

  INDEX_NAME = 'index_name'

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

Grafana를 통해 인덱스가 사용되고 있지 않은지 확인할 수 있습니다:

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

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

소규모 테이블(예: 빈 테이블이거나 1,000개 미만의 레코드를 가진 테이블)의 경우, disable_ddl_transaction!이 필요하지 않은 다른 작업과 결합하여 단일 트랜잭션 마이그레이션에서 remove_index를 사용하는 것이 권장됩니다.

인덱스 비활성화#

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

인덱스 추가#

인덱스를 추가하기 전에 필요한지 여부를 고려하세요. 데이터베이스 인덱스 추가 가이드에는 인덱스가 필요한지 결정하는 데 도움이 되는 세부 정보와 인덱스 추가 모범 사례가 포함되어 있습니다.

고유 인덱스#

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

인덱스 존재 여부 테스트#

마이그레이션이 인덱스의 부재 또는 존재 여부에 따라 조건부 로직을 필요로 하는 경우, 해당 인덱스의 이름을 사용하여 존재 여부를 테스트해야 합니다. 이는 Rails가 인덱스 정의를 비교하는 방식으로 인해 발생할 수 있는 예상치 못한 결과를 방지하는 데 도움이 됩니다.

자세한 내용은 데이터베이스 인덱스 추가를 참조하세요.

가이드.

NOT NULL 제약 조건#

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

기본값이 있는 칼럼 추가#

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

PostgreSQL 11 이전에는 기본값이 있는 칼럼을 추가하면 테이블 전체 재작성을 유발하기 때문에 문제가 있었습니다.

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

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

  • 표준 마이그레이션에서 기본값이 있는 칼럼을 추가합니다.

  • 배포 후 마이그레이션(post-deployment migration)에서 기본값을 제거합니다.

배포 후 마이그레이션은 애플리케이션이 재시작된 후 실행되므로, 새 칼럼이 인식된 상태가 됩니다.

칼럼 기본값 변경#

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에서 [null이 아닌 기본값을 가진 ALTER TABLE ADD COLUMN의 빠른 처리](https://www.depesz.com/2018/04/04/waiting-for-postgresql-11-fast-alter-table-add-column-with-a-non-null-default/)가 도입되어, 기본값이 있는 새 칼럼 추가 시 테이블 재작성이 필요 없게 되었습니다.

위에서 언급한 이유로, disable_ddl_transaction! 없이 단일 트랜잭션 마이그레이션에서 change_column_default를 안전하게 사용할 수 있습니다.

기존 칼럼 업데이트#

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

다음 예시는 projects 테이블의 foo 칼럼을 10으로 업데이트하되, some_column'hello'인 경우에만 적용합니다:

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

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

테이블이 삭제된 후에는 [데이터베이스 사전 가이드](/19.1/development/database/database_dictionary/#dropping-tables)의

단계에 따라 데이터베이스 사전에 추가해야 합니다.

데이터베이스 테이블 삭제는 드문 작업이며, Rails에서 제공하는 drop_table 메서드는 일반적으로 안전한 것으로 간주됩니다. 테이블을 삭제하기 전에 다음 사항을 고려하세요:

테이블에 트래픽이 많은 테이블(예: projects)에 대한 외래 키가 있는 경우, DROP TABLE 구문은 statement timeout 오류와 함께 실패할 때까지 동시 트래픽을 차단할 가능성이 높습니다.

테이블에 레코드가 없고(기능이 한 번도 사용되지 않은 경우) 외래 키도 없는 경우:

  • 마이그레이션에서 drop_table 메서드를 사용하세요.
def change
  drop_table :my_table
end

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

  • 모델, 컨트롤러, 서비스 등 테이블과 관련된 애플리케이션 코드를 제거하세요.

  • 배포 후 마이그레이션(post-deployment migration)에서 drop_table을 사용하세요.

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

def up
  drop_table :my_table
end

def down
  # create_table ...
end

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

  • 모델, 컨트롤러, 서비스 등 테이블과 관련된 애플리케이션 코드를 제거하세요.

  • 배포 후 마이그레이션에서 with_lock_retries 헬퍼 메서드를 사용하여 외래 키를 제거하세요. 여러 외래 키를 제거하는 경우, 잠금 경합(lock contention)을 방지하기 위해 각 키를 별도의 마이그레이션에서 삭제해야 합니다.

  • 그 다음 별도의 배포 후 마이그레이션에서 drop_table을 사용하세요.

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

비트랜잭션 마이그레이션을 사용하여 projects 테이블의 외래 키 제거:

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

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

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

테이블 삭제:

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

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

시퀀스 삭제#

History

시퀀스 삭제는 드문 작업이지만, 데이터베이스 팀이 제공하는 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)#

History

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

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

  • 자를 테이블의 gitlab_schema를 찾습니다.

  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있으면 TRUNCATE 구문을 실행합니다.

  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있지 않으면 아무 작업도 수행하지 않습니다.

기본 키 교체#

History

테이블을 파티셔닝하려면 기본 키를 교체해야 합니다. 파티션 키는 반드시 기본 키에 포함되어야 하기 때문입니다.

데이터베이스 팀이 제공하는 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비트) 숫자를 저장할 수 있도록 하려면, 명시적으로 limit을 8바이트로 설정합니다. 이렇게 하면 칼럼이 최대 9,223,372,036,854,775,807 값을 저장할 수 있습니다.

Rails 마이그레이션 예시:

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

문자열 및 Text 데이터 타입#

자세한 내용은 Text 데이터 타입 스타일 가이드를 참조하세요.

타임스탬프 칼럼 타입#

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

또한, Rails는 :datetime 데이터 타입을 timestamp로 변환합니다.

예시:

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

# add_timestamps
def up
  add_timestamps :users
end

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

이러한 메서드 대신, 타임존이 포함된 타임스탬프를 저장하려면 다음 메서드를 사용해야 합니다:

  • add_timestamps_with_timezone

  • timestamps_with_timezone

  • datetime_with_timezone

이를 통해 모든 타임스탬프에 타임존이 지정됩니다. 결과적으로, 시스템의 타임존이 변경되더라도 기존 타임스탬프가 갑자기 다른 타임존을 사용하는 문제를 방지합니다. 또한 처음에 어떤 타임존이 사용되었는지 명확하게 알 수 있습니다.

데이터베이스에 JSON 저장하기#

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

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

기본적으로 해시 키는 문자열로 처리됩니다. 선택적으로 커스텀 데이터 타입을 추가하여 키에 다른 방식으로 접근할 수 있습니다.

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

JSONB 칼럼을 사용할 때는 JsonSchemaValidator를 사용하여 시간이 지남에 따라 삽입되는 데이터를 제어해야 합니다. 또한 대용량 JSONB 데이터로 인한 성능 문제를 방지하기 위해 size_limit을 반드시 지정해야 하며, 권장 최대값은 64 KB입니다.

JSON 스키마에서 additionalProperties: false를 사용하는 경우, 속성을 추가하거나 제거할 때의 배포 요구사항은 스키마 유효성 검사가 있는 JSON/JSONB 칼럼 변경하기를 참조하세요.

JsonbSizeLimit cop은 새로운 유효성 검사에 대해 이 요구사항을 강제합니다. 제한 없는 JSONB 증가는 수백만 개의 데이터베이스 레코드에 걸쳐 메모리 압박과 느린 쿼리 성능을 유발할 수 있기 때문입니다. 대용량 데이터셋의 경우 오브젝트 스토리지를 사용하고 데이터베이스에는 참조만 저장하세요.

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

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

module Organizations
  class OrganizationSetting < ApplicationRecord
    belongs_to :organization

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

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

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

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

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

암호화된 속성#

encrypts 속성을 데이터베이스에 :text로 저장하지 마세요. 대신

:jsonb를 사용하세요. 이는 PostgreSQL에서 JSONB 타입을 사용하여 스토리지를 더 효율적으로 만듭니다:

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

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

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

타입 안전성을 갖춘 향상된 유효성 검사#

추가적인 데이터 무결성을 위해, 암호화 전에 평문 값의 형식과 타입을 모두 유효성 검사하세요:

class Something < ApplicationRecord
  encrypts :secret

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

  validate :ensure_string_type

  private

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

이 방식은 Rails의 format 유효성 검사기를 사용하여 평문 값(기반이 되는 JSONB 값이 아님)을 검증하고, 속성이 String 또는 nil임을 확인하여 타입 안전성을 보장합니다.

테스팅#

Rails 마이그레이션 테스팅 스타일 가이드를 참고하세요.

데이터 마이그레이션#

일반적인 ActiveRecord 구문 대신 Arel과 순수 SQL을 사용하는 것을 권장합니다. 순수 SQL을 사용하는 경우, quote_string 헬퍼로 모든 입력을 수동으로 이스케이프 처리해야 합니다.

Arel을 사용하는 예시:

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

#update other tables with these results

순수 SQL과 quote_string 헬퍼를 사용하는 예시:

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

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

더 복잡한 로직이 필요한 경우, 마이그레이션에 로컬 모델을 정의하여 사용할 수 있습니다. 예를 들면:

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

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

    # ... ...
  end
end

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

마이그레이션에서 모델을 사용할 때의 제한 사항에 유의하세요.

기존 데이터 수정#

대부분의 경우, 데이터베이스에서 데이터를 수정할 때는 배치(batch) 단위로 데이터를 마이그레이션하는 것을 권장합니다.

컬렉션을 성능 효율적으로 반복 처리하는 과정을 지원하는 헬퍼 each_batch_range를 사용하세요. 배치의 기본 크기는 BATCH_SIZE 상수에 정의되어 있습니다.

아이디어를 얻기 위해 다음 예시를 참고하세요.

배치 단위로 데이터 삭제하기:

disable_ddl_transaction!

def up
  each_batch_range('ci_pending_builds', scope: ->(table) { table.ref_protected }, of: BATCH_SIZE) do |min, max|
    execute <<~SQL
      DELETE FROM ci_pending_builds
        USING ci_builds
        WHERE ci_builds.id = ci_pending_builds.build_id
          AND ci_builds.status != 'pending'
          AND ci_builds.type = 'Ci::Build'
          AND ci_pending_builds.id BETWEEN #{min} AND #{max}
    SQL
  end
end
  • 첫 번째 인수는 수정되는 테이블입니다: 'ci_pending_builds'.

  • 두 번째 인수는 선택할 관련 데이터셋을 가져오는 람다를 호출합니다(기본값은 .all로 설정됨): scope: ->(table) { table.ref_protected }.

  • 세 번째 인수는 배치 크기입니다(BATCH_SIZE 상수에 기본값이 설정됨): of: BATCH_SIZE.

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

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

마이그레이션에서 애플리케이션 코드(모델 포함)를 사용하는 것은 일반적으로 권장하지 않습니다. 이는 마이그레이션이 오랫동안 유지되고, 의존하는 애플리케이션 코드가 변경되어 향후 마이그레이션을 깨뜨릴 수 있기 때문입니다. 과거에 일부 백그라운드 마이그레이션은 여러 파일에 걸쳐 수백 줄의 코드를 마이그레이션에 복사하는 것을 피하기 위해 애플리케이션 코드를 사용해야 했습니다. 이러한 드문 경우에는 마이그레이션에 충분한 테스트가 있는지 확인하는 것이 매우 중요합니다. 그래야 향후 코드를 리팩토링하는 사람이 마이그레이션을 깨뜨렸는지 알 수 있습니다. 애플리케이션 코드 사용은 배치 백그라운드 마이그레이션에서도 권장하지 않으며 , 모델은 마이그레이션 내에서 선언해야 합니다.

일반적으로 MigrationRecord를 상속하는 클래스를 정의함으로써 마이그레이션에서 애플리케이션 코드(특히 모델)를 사용하지 않을 수 있습니다(아래 예시 참조).

모델을 사용하는 경우(마이그레이션 내에서 정의된 모델 포함), 먼저 reset_column_information을 사용하여 칼럼 캐시를 지워야 합니다.

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

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

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

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

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

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

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

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

기반 테이블이 수정된 후 ActiveRecord를 사용하여 접근합니다.

이는 동일한 db:migrate 프로세스에서 두 마이그레이션이 실행되는 경우, 이전의 다른 마이그레이션에서 테이블이 수정된 경우에도 사용해야 합니다.

결과는 다음과 같습니다. my_column이 포함된 것을 확인하세요:

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

스키마 캐시 지우기(User.reset_column_information)를 건너뛰면, 칼럼이 ActiveRecord에서 사용되지 않아 의도한 변경이 이루어지지 않습니다. 아래 결과에서 my_column이 쿼리에 누락된 것을 확인할 수 있습니다.

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

트래픽이 많은 테이블#

현재 고트래픽 테이블 목록을 확인할 수 있습니다.

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

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

현재 고트래픽 테이블과 비교하여 읽기 작업 수가 높은 테이블은 좋은 후보가 될 수 있습니다.

일반적인 원칙으로, GitLab.com의 분석 또는 보고 목적으로만 사용되는 칼럼을 고트래픽 테이블에 추가하는 것은 권장하지 않습니다. 이는 해당 기능의 직접적인 가치를 제공하지 않으면서도 모든 GitLab Self-Managed 인스턴스에 성능 부하를 줄 수 있습니다.

트리거 생성#

고트래픽 테이블에 트리거를 생성하면 배포 중 잠금 경쟁 타임아웃이 발생할 수 있습니다. 이를 완화하기 위해 with_lock_retries 헬퍼 메서드를 사용하는 포스트 배포 마이그레이션에서 트리거를 생성할 수 있습니다. 또한 마이그레이션이 중간에 실패하더라도 재시도할 수 있도록 멱등성을 보장해야 하며, 함수나 트리거가 이미 존재하더라도 실패하지 않아야 합니다.

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

  disable_ddl_transaction!

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

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

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

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

    drop_function(TRIGGER_FUNCTION_NAME, if_exists: true)
  end
end

with_lock_retries를 사용하려면 disable_ddl_transaction!이 필요합니다. create_trigger 헬퍼를 사용하여 트리거를 생성할 수 없는 경우(예: 트리거가 각 행이 아닌 각 구문에 대해 실행되는 경우), 트리거 생성 시 CREATE OR REPLACE TRIGGER를 사용하세요.

마일스톤#

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

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

  def change
    # Your migration here
  end
end

마이그레이션에 올바른 마일스톤을 추가하면 마이그레이션을 해당 GitLab 마이너 버전으로 논리적으로 분류할 수 있습니다. 이를 통해:

  • 업그레이드 프로세스가 단순화됩니다.

  • 마이그레이션의 타임스탬프만으로 순서를 결정할 때 발생할 수 있는 잠재적인 마이그레이션 순서 문제가 완화됩니다.

Autovacuum 래핑 방지#

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

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

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

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

GitLab v19.1
원문 보기
요약

또한 규모에 관계없이 업그레이드를 위해 서버를 오프라인으로 전환해야 하는 상황은 대부분의 조직에게 큰 부담입니다. 마이그레이션은 GitLab 설치를 절대 오프라인으로 전환하도록 요구해서는 안 됩니다. 마이그레이션을 작성할 때는 데이터베이스에 오래된 데이터나 불일치가 있을 수 있다는 점도 고려하고 이를 방어적으로 처리하세요.


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

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

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

마이그레이션은 GitLab 설치를 절대 오프라인으로 전환하도록 요구해서는 됩니다. 마이그레이션은 항상 다운타임을 피하는 방식으로 작성되어야 합니다. 과거에는 DOWNTIME 상수를 설정하여 다운타임을 허용하는 마이그레이션을 정의하는 프로세스가 있었습니다. 오래된 마이그레이션을 살펴보면 이를 확인할 수 있습니다. 이 프로세스는 4년 동안 유지되었지만 실제로 사용된 적은 없으며, 이를 통해 다운타임을 피하기 위해 마이그레이션을 항상 다르게 작성하는 방법을 찾아낼 수 있다는 것을 배웠습니다.

마이그레이션을 작성할 때는 데이터베이스에 오래된 데이터나 불일치가 있을 수 있다는 점도 고려하고 이를 방어적으로 처리하세요. 데이터베이스 상태에 대해 가능한 한 가정을 적게 세우도록 노력하세요.

미래 버전에서 변경될 수 있으므로 GitLab 특정 코드에 의존하지 마세요. 필요한 경우 GitLab 코드를 마이그레이션에 복사하여 앞으로도 호환 가능하게 만드세요.

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

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

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

일반 스키마 마이그레이션. 이는 새 애플리케이션 코드가 배포되기 이전에 실행되는 db/migrate의 전통적인 Rails 마이그레이션입니다 (GitLab.com의 경우 Canary가 배포되기 전). 즉, 배포를 불필요하게 지연시키지 않도록 몇 분 이내로 비교적 빠르게 실행되어야 합니다.

예외적으로 시간이 더 걸리지만 애플리케이션이 올바르게 동작하는 데 절대적으로 중요한 마이그레이션이 있을 수 있습니다. 예를 들어 고유한 튜플을 강제하는 인덱스나 애플리케이션의 중요한 부분에서 쿼리 성능에 필요한 인덱스가 있을 수 있습니다. 그러나 마이그레이션이 허용할 수 없을 정도로 느린 경우, 피처 플래그로 기능을 보호하고 대신 배포 후 마이그레이션을 수행하는 것이 더 나은 방법일 수 있습니다. 마이그레이션이 완료된 후 기능을 활성화하면 됩니다.

새 모델을 추가하는 마이그레이션도 이 일반 스키마 마이그레이션의 일부입니다. 차이점은 마이그레이션 생성에 사용하는 Rails 명령어와 추가로 생성되는 파일(모델용 파일 하나, 모델의 스펙 파일 하나)뿐입니다.

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

사용하지 않는 칼럼 제거와 같은 정리 작업.

  • 트래픽이 많은 테이블에 중요하지 않은 인덱스 추가.

  • 생성하는 데 오랜 시간이 걸리는 중요하지 않은 인덱스 추가.

이 마이그레이션은 애플리케이션 동작에 중요한 스키마 변경에는 사용해서는 안 됩니다. 배포 후 마이그레이션에서 이러한 스키마 변경을 수행하면 과거에 문제가 발생한 사례가 있습니다. 예를 들어 이 이슈가 있습니다. 항상 일반 스키마 마이그레이션이어야 하며 배포 후 마이그레이션에서 실행해서는 안 되는 변경 사항은 다음과 같습니다:

  • 새 테이블 생성, 예: create_table.

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

    배포 후 마이그레이션은 PDM으로 약칭되는 경우가 많습니다.

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

다음 다이어그램을 참고하여 결정을 내리되, 이는 단순한 도구일 뿐이며 최종 결과는 항상 구체적인 변경 사항에 따라 달라진다는 점을 염두에 두세요:

graph LR A{Schema
changed?} A -->|Yes| C{Critical to
speed or
behavior?} A -->|No| D{Is it fast?}

C -->|Yes| H{Is it fast?}
C -->|No| F[Post-deploy migration]

H -->|Yes| E[Regular migration]
H -->|No| I[Post-deploy migration<br/>&#43; feature flag]

D -->|Yes| F[Post-deploy migration]
D -->|No| G[Background migration]

데이터베이스 인덱스를 추가할 때 사용할 마이그레이션 유형 선택에 대해서는 사용할 마이그레이션 유형을 참고하세요.

마이그레이션 소요 시간#

일반적으로, 단일 배포에 대한 모든 마이그레이션은 GitLab.com 기준으로 1시간을 초과해서는 안 됩니다. 다음 가이드라인은 엄격한 규칙이 아니며, 마이그레이션 소요 시간을 최소화하기 위해 추정된 기준입니다.

모든 소요 시간은 GitLab.com을 기준으로 측정해야 함을 유의하세요.

데이터베이스 마이그레이션 파이프라인의 결과에는 마이그레이션에 대한 타이밍 정보가 포함되어 있습니다.

동시 작업 및 백그라운드 마이그레이션을 포함한 쿼리 수준 타이밍 제한에 대해서는 쿼리 성능 가이드라인을 참고하세요.

마이그레이션 유형 권장 소요 시간 비고
일반 마이그레이션 3분 이하 애플리케이션 기능 또는 성능이 심각하게 저하될 수 있고 지연할 수 없는 변경 사항은 유효한 예외입니다.
배포 후 마이그레이션 10분 이하 스키마 변경은 백그라운드 마이그레이션에서 수행할 수 없으므로 유효한 예외입니다. 인덱스 생성과 같은 동시 작업은 별도로 20분 제한이 있습니다.
백그라운드 마이그레이션 10분 초과 대용량 테이블에 적합하므로 정확한 타이밍 가이드라인을 설정할 수 없지만, 단일 쿼리는 콜드 캐시 기준 실행 시간이 1초 미만이어야 합니다.

db:gitlabcom-database-testing 파이프라인이 인덱스 생성에 20분 이상이 소요된다고 보고하는 경우, 인덱스를 비동기적으로 생성하세요. 테스트 파이프라인은 실제 GitLab.com 실행 시간을 과소평가할 수 있는 데이터베이스 클론에서 실행되므로, 이 임계값은 의도적으로 보수적으로 설정되어 있습니다.

대형 테이블 제한 사항#

크기 임계값을 초과하는 테이블의 경우, 새 칼럼 또는 인덱스를 추가하기 전에 대형 테이블 제한 사항을 먼저 확인하세요.

타깃 데이터베이스 결정#

GitLab은 mainci 두 가지 서로 다른 Postgres 데이터베이스에 연결합니다. 이 분리로 인해 마이그레이션이 두 데이터베이스 중 하나 또는 모두에서 실행될 수 있습니다.

추가하는 마이그레이션이 이를 고려해야 하는지 또는 어떻게 고려해야 하는지 이해하려면 여러 데이터베이스에 대한 마이그레이션을 참고하세요.

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

마이그레이션을 생성하려면 다음 Rails 제너레이터를 사용할 수 있습니다.

bundle exec rails g migration migration_name_here

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

새 모델 추가를 위한 일반 스키마 마이그레이션#

새 모델을 생성하려면 다음 Rails 제너레이터를 사용할 수 있습니다.

bundle exec rails g model model_name_here

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

  • db/migrate에 마이그레이션 파일

  • app/models에 모델 파일

  • spec/models에 스펙 파일

스키마 변경#

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

[인덱스를 비동기적으로 생성하려면 두 개의 머지 리퀘스트가 필요합니다.](/19.1/development/database/adding_database_indexes/#add-a-migration-to-create-the-index-synchronously)

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

GDK의 로컬 데이터베이스가 main의 스키마에서 벗어날 경우, 스키마 변경 사항을 Git에 깔끔하게 커밋하기 어려울 수 있습니다. 이 경우 scripts/regenerate-schema 스크립트를 사용하여 추가하려는 마이그레이션에 대해 깔끔한 db/structure.sql을 재생성할 수 있습니다. 이 스크립트는 db/migrate 또는 db/post_migrate에 있는 모든 마이그레이션을 적용하므로, 스키마에 커밋하지 않으려는 마이그레이션이 있다면 이름을 변경하거나 삭제하세요. 브랜치가 기본 Git 브랜치를 타깃으로 하지 않는 경우, TARGET 환경 변수를 설정할 수 있습니다.

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

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

scripts/regenerate-schema 스크립트는 추가적인 차이를 만들 수 있습니다. 이 경우, <migration ID>가 마이그레이션 파일의 DATETIME 부분인 수동 절차를 사용하세요.

# Rebase against master
git rebase master

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

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

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

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

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

마이그레이션이 처음 실행되면, 마이그레이션의 타임스탬프에서 생성된 SHA256을 포함하는 새로운 마이그레이션 체크섬 파일db/schema_migrations에 생성됩니다. 이 새 파일의 이름은 마이그레이션 파일명의 타임스탬프 부분과 동일합니다. 예를 들면 db/schema_migrations/20241021120146과 같습니다. 이 파일의 내용은 타임스탬프 부분의 SHA256이며, 예를 들면 다음과 같습니다:

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

SHA256은 파일에 고유한 콘텐츠를 추가하여 Git의 이름 변경 감지가 이를 별개의 파일로 인식하도록 합니다.

마이그레이션 체크섬 파일은 마이그레이션이 성공적으로 실행되었으며 그 결과가 db/structure.sql에 기록되었음을 나타냅니다. 이 파일이 존재하면 동일한 마이그레이션이 두 번 실행되는 것을 방지하므로, 새 마이그레이션을 추가하는 머지 리퀘스트에 반드시 이 파일을 포함시켜야 합니다.

db/schema_migrations 디렉터리에 대한 자세한 내용은 개발 변경 사항: structure.sql 외부의 데이터베이스 스키마 버전 처리를 참조하십시오.

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

  • 새 마이그레이션이 생성되면 rake db:migrate를 실행하여 마이그레이션을 실행하고 해당 db/schema_migration/<timestamp> 체크섬 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.

  • 마이그레이션이 삭제되면 해당 db/schema_migration/<timestamp> 체크섬 파일을 삭제합니다.

  • 마이그레이션의 타임스탬프 부분이 변경되면 해당 db/schema_migration/<timestamp> 체크섬 파일을 삭제하고 rake db:migrate를 실행하여 새 파일을 생성한 다음, 이 파일을 버전 관리에 추가합니다.

  • 마이그레이션의 내용이 변경된 경우, db/schema_migration/<timestamp> 체크섬 파일을 변경할 필요가 없습니다.

다운타임 방지#

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

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

가역성#

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

GitLab 프로덕션 환경에서는 문제가 발생하면 `db:rollback`을 사용하여 마이그레이션을 롤백하는 대신 롤포워드 전략을 사용합니다.

GitLab Self-Managed의 경우, 업그레이드 프로세스가 시작되기 전에 생성된 백업을 복원하도록 사용자에게 권장합니다. down 메서드는 주로 개발 환경에서 사용되며, 예를 들어 개발자가 커밋이나 브랜치 간 전환 시 로컬의 structure.sql 파일과 데이터베이스가 일관된 상태인지 확인하고자 할 때 사용됩니다.

마이그레이션에 마이그레이션의 가역성이 어떻게 테스트되었는지 설명하는 주석을 추가하십시오.

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

def down
  # no-op

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

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

원자성과 트랜잭션#

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

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

  • 모든 마이그레이션을 하나의 단일 트랜잭션 마이그레이션에 넣습니다.

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

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

서브트랜잭션은 일반적으로 [허용되지 않습니다](https://about.gitlab.com/blog/why-we-spent-the-last-month-eliminating-postgresql-subtransactions/).

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

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

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

단일 트랜잭션 마이그레이션이 완료되는 데 시간이 오래 걸리는 경우 여러 가지 옵션이 있습니다. 모든 경우에 마이그레이션 소요 시간에 따라 적절한 마이그레이션 유형을 선택하는 것을 잊지 마십시오.

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

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

구문 및 잠금 타임아웃 설정을 조정한 후 단일 트랜잭션 마이그레이션을 계속 사용하세요. 무거운 워크로드가 반드시 트랜잭션의 보장을 사용해야 한다면, 마이그레이션이 타임아웃 제한에 걸리지 않고 실행될 수 있는지 확인해야 합니다. 동일한 권고 사항이 단일 트랜잭션 마이그레이션과 개별 트랜잭션 모두에 적용됩니다.

구문 타임아웃: 구문 타임아웃은 GitLab.com의 프로덕션 데이터베이스에서 15s로 설정되어 있지만, 인덱스를 생성하는 데는 15초 이상이 걸리는 경우가 많습니다. add_concurrent_index를 포함한 기존 헬퍼를 사용하면, 필요 시 구문 타임아웃이 자동으로 비활성화됩니다. 드물게 disable_statement_timeout을 사용하여 타임아웃 제한을 직접 설정해야 할 수도 있습니다.

마이그레이션을 실행하기 위해 PgBouncer를 우회하여 프라이머리 데이터베이스에 직접 연결하며,

statement_timeoutlock_wait_timeout과 같은 설정을 제어합니다.

구문 타임아웃 제한 일시 비활성화#

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

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

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

disable_statement_timeout 사용은 거의 필요하지 않습니다. 대부분의 마이그레이션 헬퍼가 필요 시 내부적으로 이미 사용하고 있기 때문입니다. 예를 들어, 인덱스 생성은 일반적으로 15초 이상 걸리는데, 이것이 GitLab.com의 프로덕션 데이터베이스에 설정된 기본 구문 타임아웃입니다. 헬퍼 add_concurrent_index는 연결별 구문 타임아웃을 비활성화하기 위해 disable_statement_timeout에 전달된 블록 내에서 인덱스를 생성합니다.

마이그레이션에서 원시 SQL 구문을 작성하는 경우, disable_statement_timeout을 수동으로 사용해야 할 수도 있습니다. 이 경우 데이터베이스 리뷰어 및 메인테이너와 상담하세요.

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

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

“이 마이그레이션을 단일 PostgreSQL 트랜잭션으로 실행하지 않습니다. PostgreSQL 트랜잭션은 필요할 경우에만 열겠습니다.”

명시적 PostgreSQL 트랜잭션 `.transaction`(또는 `BEGIN; COMMIT;`)을 사용하지 않더라도,

모든 SQL 구문은 여전히 트랜잭션으로 실행됩니다. 트랜잭션에 대한 PostgreSQL 문서를 참조하세요.

GitLab에서는 disable_ddl_transaction!을 사용하는 마이그레이션을 비트랜잭션 마이그레이션이라고 부르는 경우가 있었습니다. 이는 단지 해당 마이그레이션이 단일 트랜잭션으로 실행되지 않았다는 것을 의미합니다.

disable_ddl_transaction!은 언제 사용해야 할까요? 대부분의 경우, 기존 RuboCop 규칙이나 마이그레이션 헬퍼가 disable_ddl_transaction!을 사용해야 하는지 여부를 감지할 수 있습니다. 사용 여부가 불분명한 경우 disable_ddl_transaction!을 건너뛰고, RuboCop 규칙과 데이터베이스 리뷰의 안내를 따르세요.

PostgreSQL이 명시적 트랜잭션 외부에서 작업을 실행하도록 요구하는 경우 disable_ddl_transaction!을 사용하세요.

  • 이러한 작업의 가장 대표적인 예는 CREATE INDEX CONCURRENTLY 명령입니다. PostgreSQL은 블로킹 버전(CREATE INDEX)을 트랜잭션 내에서 실행할 수 있도록 허용합니다. CREATE INDEX와 달리, CREATE INDEX CONCURRENTLY는 트랜잭션 외부에서 실행해야 합니다. 따라서 마이그레이션이 CREATE INDEX CONCURRENTLY 구문 하나만 실행하더라도, disable_ddl_transaction!을 비활성화해야 합니다. 헬퍼 add_concurrent_index를 사용하려면 disable_ddl_transaction!이 필요한 것도 이 때문입니다. CREATE INDEX CONCURRENTLY는 일반적인 규칙보다는 예외에 해당합니다.

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

  • 예를 들어, 대량의 데이터를 삽입, 업데이트, 또는 삭제(DML)하는 경우, 배치로 수행해야 합니다. 각 배치에 대해 작업을 그룹화해야 하는 경우, 배치를 처리할 때 명시적으로 트랜잭션 블록을 열 수 있습니다. 합리적으로 큰 워크로드에는 배치 백그라운드 마이그레이션을 사용하는 것을 고려하세요.

마이그레이션 헬퍼가 필요로 하는 경우 disable_ddl_transaction!을 사용하세요. 다양한 마이그레이션 헬퍼는 트랜잭션을 언제, 어떻게 열지에 대한 정밀한 제어가 필요하기 때문에 disable_ddl_transaction!과 함께 실행해야 합니다.

  • 외래 키는 CREATE INDEX CONCURRENTLY와 달리 트랜잭션 내에서 추가할 수 있습니다. 그러나 PostgreSQL은 CREATE INDEX CONCURRENTLY와 유사한 옵션을 제공하지 않습니다. 헬퍼 add_concurrent_foreign_key는 외래 키를 추가하고 유효성을 검사하는 동안 잠금을 최소화하는 방식으로 소스 테이블과 타깃 테이블을 잠그기 위해 자체 트랜잭션을 엽니다.

  • 앞서 권고한 대로, 확실하지 않은 경우 disable_ddl_transaction!을 건너뛰고 RuboCop 검사를 위반하는지 확인하세요.

마이그레이션이 실제로 PostgreSQL 데이터베이스에 접근하지 않거나 여러 PostgreSQL 데이터베이스에 접근하는 경우 disable_ddl_transaction!을 사용하세요.

  • 예를 들어, 마이그레이션이 Redis 서버를 타깃으로 할 수 있습니다. 원칙적으로 PostgreSQL 트랜잭션 내에서 외부 서비스와 상호 작용할 수 없습니다.

  • 트랜잭션은 단일 데이터베이스 연결에 사용됩니다. 마이그레이션이 ci 데이터베이스와 main 데이터베이스 등 여러 데이터베이스를 타깃으로 하는 경우, 여러 데이터베이스를 위한 마이그레이션을 따르세요.

명명 규칙#

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

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

커스텀 인덱스 및 제약 조건 이름은 제약 조건 명명 규칙 가이드라인을 따라야 합니다.

긴 인덱스 이름 줄이기#

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

  • index_ 대신 i_ 접두사를 사용합니다.

  • 중복되는 접두사를 생략합니다. 예를 들어, index_vulnerability_findings_remediations_on_vulnerability_remediation_idindex_vulnerability_findings_remediations_on_remediation_id로 줄일 수 있습니다.

  • 칼럼 이름 대신 index_users_for_unconfirmation_notification과 같이 인덱스의 목적을 명시합니다.

마이그레이션 타임스탬프 시기#

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

  • 마이그레이션이 GitLab 코드베이스에 추가되는 시점.

  • 마이그레이션 자체의 타임스탬프.

새 마이그레이션의 타임스탬프는 이전 필수 업그레이드 중단점보다 앞서서는 안 됩니다. 마이그레이션은 때때로 스쿼시(squash)되는데, 이전 필수 중단점 이전에 해당하는 타임스탬프를 가진 마이그레이션이 추가되면 이슈 408304에서 발생한 것과 같은 문제가 생길 수 있습니다.

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

모범 사례#

위 내용은 반드시 지켜야 할 규칙으로 간주해야 하지만, 마이그레이션 타임스탬프를 마지막 필수 중단점 이후 경과 시간과 무관하게 머지될 것으로 예상되는 날짜 기준 3주 이내로 유지하는 것이 모범 사례입니다.

마이그레이션 타임스탬프를 업데이트하려면:

cimain 데이터베이스에 대해 마이그레이션을 다운(migrate down)합니다:

rake db:migrate:down:main VERSION=<timestamp>
rake db:migrate:down:ci VERSION=<timestamp>

마이그레이션 파일을 삭제합니다.

마이그레이션 스타일 가이드에 따라 마이그레이션을 다시 생성합니다.

또는 다음 스크립트를 사용하여 모든 마이그레이션 타임스탬프를 갱신할 수 있습니다:

scripts/refresh-migrations-timestamps

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

  • 모든 마이그레이션 타임스탬프를 현재 시각으로 업데이트합니다.

  • 마이그레이션의 상대적 순서를 유지합니다.

  • 파일명과 마이그레이션 클래스 내부의 타임스탬프를 모두 업데이트합니다.

  • 일반 마이그레이션과 배포 후(post-deployment) 마이그레이션 모두 처리합니다.

    마이그레이션이 오랜 시간(> 3주) 리뷰 중이었거나 오래된 마이그레이션 브랜치를 리베이스할 때, 머지 전에 이 스크립트를 실행하세요.

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

데이터베이스 마이그레이션의 많은 공통 패턴에 대해 다양한 헬퍼 메서드를 사용할 수 있습니다. 이러한 헬퍼는 Gitlab::Database::MigrationHelpers 및 관련 모듈에서 찾을 수 있습니다.

헬퍼의 동작을 시간이 지남에 따라 변경할 수 있도록, 마이그레이션 헬퍼에 대한 버전 관리 체계를 구현합니다. 이를 통해 이미 존재하는 마이그레이션에 대해서는 헬퍼의 동작을 유지하면서도, 새 마이그레이션에 대해서는 동작을 변경할 수 있습니다.

이를 위해 모든 데이터베이스 마이그레이션은 “버전이 지정된(versioned)” 클래스인 Gitlab::Database::Migration을 상속해야 합니다. 새 마이그레이션에는 최신 버전의 마이그레이션 헬퍼를 사용하기 위해 최신 버전을 사용해야 하며(Gitlab::Database::Migration::MIGRATION_CLASSES에서 조회 가능), 이를 통해 최신 버전의 마이그레이션 헬퍼를 사용할 수 있습니다.

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

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

마이그레이션에 Gitlab::Database::MigrationHelpers를 직접 포함(include)하지 마세요. 대신 최신 버전의 Gitlab::Database::Migration을 사용하면 최신 버전의 마이그레이션 헬퍼가 자동으로 제공됩니다.

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

데이터베이스 스키마를 변경할 때, DDL(Data Definition Language) 구문을 실행하기 위해 헬퍼 메서드를 사용합니다. 경우에 따라 이러한 DDL 구문은 특정 데이터베이스 락이 필요합니다.

예시:

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

이 마이그레이션을 실행하려면 users 테이블에 대한 배타적 락(exclusive lock)이 필요합니다. 다른 프로세스가 테이블에 동시에 접근하거나 수정하는 경우, 락을 획득하는 데 시간이 걸릴 수 있습니다. 락 요청은 대기열에서 기다리며, 일단 대기열에 들어가면 users 테이블에 대한 다른 쿼리도 차단될 수 있습니다.

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

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

이 문제는 애플리케이션 업그레이드 프로세스 실패와 심지어 애플리케이션 안정성 문제를 야기할 수 있습니다. 테이블이 짧은 시간 동안 접근 불가 상태가 될 수 있기 때문입니다.

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

non_transactional 마이그레이션 작업 시, with_lock_retries 메서드를 사용하면 마이그레이션 내에서 실행되는 코드 블록에 대한 잠금 획득 재시도 및 타임아웃 구성을 명시적으로 제어할 수 있습니다.

트랜잭션 마이그레이션#

일반 마이그레이션은 트랜잭션 내에서 전체 마이그레이션을 실행합니다. lock-retry 메커니즘은 기본적으로 활성화되어 있습니다(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 잠금이 필요하며, 동일한 트랜잭션에서 여러 테이블을 잠그는 것은 피해야 합니다.

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

  • 외래 키 없이 테이블 생성 (인덱스 포함).

  • 첫 번째 테이블에 외래 키 추가.

  • 두 번째 테이블에 외래 키 추가.

테이블 생성:

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 retry가 내장되어 있습니다.

disable_ddl_transaction!

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

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

users에 외래 키 추가:

disable_ddl_transaction!

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

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

2단계 외래 키 유효성 검사로 잠금 경합 최소화#

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

  • 첫 번째 마이그레이션: validate: false로 외래 키를 추가하여 배포 중 쓰기 차단 방지

  • 두 번째 마이그레이션: prepare_async_foreign_key_validation을 사용하여 외래 키를 비동기적으로 유효성 검사

  • 세 번째 마이그레이션: 유효성 검사 완료 후 부모 외래 키 추가 (파티션된 테이블의 경우)

add_concurrent_foreign_key는 이미 내부적으로 2단계 유효성 검사(유효성 검사 없이 추가 후 유효성 검사)를 수행하지만, 이러한 단계를 서로 다른 마이그레이션으로 분리하면 유효성 검사가 배포 윈도우 외부에서 수행될 수 있어 배포 시간과 위험을 줄이는 배포 유연성을 제공합니다.

이 방식은 이 사례처럼 프로덕션 인시던트에서 볼 수 있듯이, 중요한 배포 윈도우 중 외래 키 유효성 검사가 예상보다 오래 걸릴 때 잠금 경합으로 인한 배포 차단을 최소화합니다.

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

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

커스텀 RuboCop 규칙에 의해 잠금 재시도 블록 내에서는 허용된 메서드만 사용할 수 있습니다.

disable_ddl_transaction!

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

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

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

disable_ddl_transaction!

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

헬퍼 메서드 사용 시기#

with_lock_retries 헬퍼 메서드는 실행이 이미 열린 트랜잭션 내부에 있지 않을 때(PostgreSQL 서브트랜잭션 사용은 권장되지 않음)에만 사용할 수 있습니다. 표준 Rails 마이그레이션 헬퍼 메서드와 함께 사용할 수 있습니다. 동일한 테이블에서 실행되는 경우 마이그레이션 헬퍼를 두 개 이상 호출해도 문제가 없습니다.

데이터베이스 마이그레이션이 트래픽이 많은 테이블 중 하나와 관련된 경우 with_lock_retries 헬퍼 메서드 사용이 권장됩니다.

변경 예시:

  • add_foreign_key / remove_foreign_key

  • add_column / remove_column

  • change_column_default

  • create_table / drop_table

with_lock_retries 메서드는 change 메서드 내에서 사용할 수 없으며, 마이그레이션을 되돌릴 수 있게 하려면 updown 메서드를 수동으로 정의해야 합니다.

헬퍼 메서드의 동작 방식#

  • 50회 반복합니다.

  • 각 반복마다 사전 설정된 lock_timeout을 설정합니다.

  • 주어진 블록 실행을 시도합니다. (remove_column).

  • LockWaitTimeout 오류가 발생하면 사전 설정된 sleep_time 동안 대기한 후 블록을 재시도합니다.

  • 오류가 발생하지 않으면 현재 반복에서 블록이 성공적으로 실행된 것입니다.

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

최악의 경우, 이 메서드는:

  • 40분에 걸쳐 최대 50회 블록을 실행합니다.

대부분의 시간은 각 반복 후 사전 설정된 대기 시간에 소비됩니다.

  • 50번째 재시도 후에는 표준 마이그레이션 호출과 같이 lock_timeout 없이 블록이 실행됩니다.

  • 잠금을 획득할 수 없으면 마이그레이션이 statement timeout 오류와 함께 실패합니다.

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

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

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

칼럼을 추가하기 위해 테이블을 변경할 때,

AccessExclusiveLock은 대부분의 잠금 유형과 충돌하며, 해당 테이블에서 이 잠금을 획득해야 합니다. 대상 테이블이 매우 바쁜 테이블인 경우, 칼럼을 추가하는 트랜잭션이 제때 AccessExclusiveLock을 획득하지 못할 수 있습니다.

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

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

이 시점에서 Transaction 1은 my_notes에 대한 RowExclusiveLock을 획득합니다. Transaction 1은 커밋 또는 중단 전에 더 많은 구문을 실행할 수 있습니다. my_notes를 참조하는 다른 유사한 동시 트랜잭션이 있을 수도 있습니다.

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

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

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

더 심각한 부작용은 일반적으로 Transaction 1과 충돌하지 않을 트랜잭션들까지 차단된다는 점입니다. Transaction 2가 AccessExclusiveLock 획득을 위해 대기 중이기 때문입니다. 정상적인 상황에서는 다른 트랜잭션이 Transaction 1과 동시에 my_notes 테이블에서 읽기 및 쓰기를 시도하더라도, 읽기·쓰기에 필요한 잠금이 Transaction 1이 보유한 RowExclusiveLock과 충돌하지 않으므로 해당 트랜잭션은 통과됩니다. 그러나 AccessExclusiveLock 획득 요청이 대기열에 추가되면, 테이블의 충돌하는 잠금에 대한 이후 요청들은 Transaction 1과 함께 동시에 실행될 수 있음에도 불구하고 차단됩니다.

with_lock_retries를 사용하면, Transaction 2는 지정된 시간 내에 잠금을 획득하지 못할 경우 빠르게 타임아웃되어 다른 트랜잭션이 진행될 수 있습니다:

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

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

SET LOCAL은 파라미터(lock_timeout) 변경을 트랜잭션 범위로 한정합니다.

인덱스 제거#

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

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

  INDEX_NAME = 'index_name'

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

Grafana를 통해 인덱스가 사용되고 있지 않은지 확인할 수 있습니다:

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

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

소규모 테이블(예: 빈 테이블이거나 1,000개 미만의 레코드를 가진 테이블)의 경우, disable_ddl_transaction!이 필요하지 않은 다른 작업과 결합하여 단일 트랜잭션 마이그레이션에서 remove_index를 사용하는 것이 권장됩니다.

인덱스 비활성화#

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

인덱스 추가#

인덱스를 추가하기 전에 필요한지 여부를 고려하세요. 데이터베이스 인덱스 추가 가이드에는 인덱스가 필요한지 결정하는 데 도움이 되는 세부 정보와 인덱스 추가 모범 사례가 포함되어 있습니다.

고유 인덱스#

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

인덱스 존재 여부 테스트#

마이그레이션이 인덱스의 부재 또는 존재 여부에 따라 조건부 로직을 필요로 하는 경우, 해당 인덱스의 이름을 사용하여 존재 여부를 테스트해야 합니다. 이는 Rails가 인덱스 정의를 비교하는 방식으로 인해 발생할 수 있는 예상치 못한 결과를 방지하는 데 도움이 됩니다.

자세한 내용은 데이터베이스 인덱스 추가를 참조하세요.

가이드.

NOT NULL 제약 조건#

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

기본값이 있는 칼럼 추가#

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

PostgreSQL 11 이전에는 기본값이 있는 칼럼을 추가하면 테이블 전체 재작성을 유발하기 때문에 문제가 있었습니다.

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

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

  • 표준 마이그레이션에서 기본값이 있는 칼럼을 추가합니다.

  • 배포 후 마이그레이션(post-deployment migration)에서 기본값을 제거합니다.

배포 후 마이그레이션은 애플리케이션이 재시작된 후 실행되므로, 새 칼럼이 인식된 상태가 됩니다.

칼럼 기본값 변경#

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에서 [null이 아닌 기본값을 가진 ALTER TABLE ADD COLUMN의 빠른 처리](https://www.depesz.com/2018/04/04/waiting-for-postgresql-11-fast-alter-table-add-column-with-a-non-null-default/)가 도입되어, 기본값이 있는 새 칼럼 추가 시 테이블 재작성이 필요 없게 되었습니다.

위에서 언급한 이유로, disable_ddl_transaction! 없이 단일 트랜잭션 마이그레이션에서 change_column_default를 안전하게 사용할 수 있습니다.

기존 칼럼 업데이트#

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

다음 예시는 projects 테이블의 foo 칼럼을 10으로 업데이트하되, some_column'hello'인 경우에만 적용합니다:

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

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

테이블이 삭제된 후에는 [데이터베이스 사전 가이드](/19.1/development/database/database_dictionary/#dropping-tables)의

단계에 따라 데이터베이스 사전에 추가해야 합니다.

데이터베이스 테이블 삭제는 드문 작업이며, Rails에서 제공하는 drop_table 메서드는 일반적으로 안전한 것으로 간주됩니다. 테이블을 삭제하기 전에 다음 사항을 고려하세요:

테이블에 트래픽이 많은 테이블(예: projects)에 대한 외래 키가 있는 경우, DROP TABLE 구문은 statement timeout 오류와 함께 실패할 때까지 동시 트래픽을 차단할 가능성이 높습니다.

테이블에 레코드가 없고(기능이 한 번도 사용되지 않은 경우) 외래 키도 없는 경우:

  • 마이그레이션에서 drop_table 메서드를 사용하세요.
def change
  drop_table :my_table
end

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

  • 모델, 컨트롤러, 서비스 등 테이블과 관련된 애플리케이션 코드를 제거하세요.

  • 배포 후 마이그레이션(post-deployment migration)에서 drop_table을 사용하세요.

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

def up
  drop_table :my_table
end

def down
  # create_table ...
end

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

  • 모델, 컨트롤러, 서비스 등 테이블과 관련된 애플리케이션 코드를 제거하세요.

  • 배포 후 마이그레이션에서 with_lock_retries 헬퍼 메서드를 사용하여 외래 키를 제거하세요. 여러 외래 키를 제거하는 경우, 잠금 경합(lock contention)을 방지하기 위해 각 키를 별도의 마이그레이션에서 삭제해야 합니다.

  • 그 다음 별도의 배포 후 마이그레이션에서 drop_table을 사용하세요.

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

비트랜잭션 마이그레이션을 사용하여 projects 테이블의 외래 키 제거:

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

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

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

테이블 삭제:

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

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

시퀀스 삭제#

History

시퀀스 삭제는 드문 작업이지만, 데이터베이스 팀이 제공하는 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)#

History

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

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

  • 자를 테이블의 gitlab_schema를 찾습니다.

  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있으면 TRUNCATE 구문을 실행합니다.

  • 테이블의 gitlab_schema가 연결의 gitlab_schema에 포함되어 있지 않으면 아무 작업도 수행하지 않습니다.

기본 키 교체#

History

테이블을 파티셔닝하려면 기본 키를 교체해야 합니다. 파티션 키는 반드시 기본 키에 포함되어야 하기 때문입니다.

데이터베이스 팀이 제공하는 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비트) 숫자를 저장할 수 있도록 하려면, 명시적으로 limit을 8바이트로 설정합니다. 이렇게 하면 칼럼이 최대 9,223,372,036,854,775,807 값을 저장할 수 있습니다.

Rails 마이그레이션 예시:

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

문자열 및 Text 데이터 타입#

자세한 내용은 Text 데이터 타입 스타일 가이드를 참조하세요.

타임스탬프 칼럼 타입#

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

또한, Rails는 :datetime 데이터 타입을 timestamp로 변환합니다.

예시:

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

# add_timestamps
def up
  add_timestamps :users
end

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

이러한 메서드 대신, 타임존이 포함된 타임스탬프를 저장하려면 다음 메서드를 사용해야 합니다:

  • add_timestamps_with_timezone

  • timestamps_with_timezone

  • datetime_with_timezone

이를 통해 모든 타임스탬프에 타임존이 지정됩니다. 결과적으로, 시스템의 타임존이 변경되더라도 기존 타임스탬프가 갑자기 다른 타임존을 사용하는 문제를 방지합니다. 또한 처음에 어떤 타임존이 사용되었는지 명확하게 알 수 있습니다.

데이터베이스에 JSON 저장하기#

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

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

기본적으로 해시 키는 문자열로 처리됩니다. 선택적으로 커스텀 데이터 타입을 추가하여 키에 다른 방식으로 접근할 수 있습니다.

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

JSONB 칼럼을 사용할 때는 JsonSchemaValidator를 사용하여 시간이 지남에 따라 삽입되는 데이터를 제어해야 합니다. 또한 대용량 JSONB 데이터로 인한 성능 문제를 방지하기 위해 size_limit을 반드시 지정해야 하며, 권장 최대값은 64 KB입니다.

JSON 스키마에서 additionalProperties: false를 사용하는 경우, 속성을 추가하거나 제거할 때의 배포 요구사항은 스키마 유효성 검사가 있는 JSON/JSONB 칼럼 변경하기를 참조하세요.

JsonbSizeLimit cop은 새로운 유효성 검사에 대해 이 요구사항을 강제합니다. 제한 없는 JSONB 증가는 수백만 개의 데이터베이스 레코드에 걸쳐 메모리 압박과 느린 쿼리 성능을 유발할 수 있기 때문입니다. 대용량 데이터셋의 경우 오브젝트 스토리지를 사용하고 데이터베이스에는 참조만 저장하세요.

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

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

module Organizations
  class OrganizationSetting < ApplicationRecord
    belongs_to :organization

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

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

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

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

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

암호화된 속성#

encrypts 속성을 데이터베이스에 :text로 저장하지 마세요. 대신

:jsonb를 사용하세요. 이는 PostgreSQL에서 JSONB 타입을 사용하여 스토리지를 더 효율적으로 만듭니다:

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

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

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

타입 안전성을 갖춘 향상된 유효성 검사#

추가적인 데이터 무결성을 위해, 암호화 전에 평문 값의 형식과 타입을 모두 유효성 검사하세요:

class Something < ApplicationRecord
  encrypts :secret

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

  validate :ensure_string_type

  private

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

이 방식은 Rails의 format 유효성 검사기를 사용하여 평문 값(기반이 되는 JSONB 값이 아님)을 검증하고, 속성이 String 또는 nil임을 확인하여 타입 안전성을 보장합니다.

테스팅#

Rails 마이그레이션 테스팅 스타일 가이드를 참고하세요.

데이터 마이그레이션#

일반적인 ActiveRecord 구문 대신 Arel과 순수 SQL을 사용하는 것을 권장합니다. 순수 SQL을 사용하는 경우, quote_string 헬퍼로 모든 입력을 수동으로 이스케이프 처리해야 합니다.

Arel을 사용하는 예시:

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

#update other tables with these results

순수 SQL과 quote_string 헬퍼를 사용하는 예시:

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

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

더 복잡한 로직이 필요한 경우, 마이그레이션에 로컬 모델을 정의하여 사용할 수 있습니다. 예를 들면:

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

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

    # ... ...
  end
end

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

마이그레이션에서 모델을 사용할 때의 제한 사항에 유의하세요.

기존 데이터 수정#

대부분의 경우, 데이터베이스에서 데이터를 수정할 때는 배치(batch) 단위로 데이터를 마이그레이션하는 것을 권장합니다.

컬렉션을 성능 효율적으로 반복 처리하는 과정을 지원하는 헬퍼 each_batch_range를 사용하세요. 배치의 기본 크기는 BATCH_SIZE 상수에 정의되어 있습니다.

아이디어를 얻기 위해 다음 예시를 참고하세요.

배치 단위로 데이터 삭제하기:

disable_ddl_transaction!

def up
  each_batch_range('ci_pending_builds', scope: ->(table) { table.ref_protected }, of: BATCH_SIZE) do |min, max|
    execute <<~SQL
      DELETE FROM ci_pending_builds
        USING ci_builds
        WHERE ci_builds.id = ci_pending_builds.build_id
          AND ci_builds.status != 'pending'
          AND ci_builds.type = 'Ci::Build'
          AND ci_pending_builds.id BETWEEN #{min} AND #{max}
    SQL
  end
end
  • 첫 번째 인수는 수정되는 테이블입니다: 'ci_pending_builds'.

  • 두 번째 인수는 선택할 관련 데이터셋을 가져오는 람다를 호출합니다(기본값은 .all로 설정됨): scope: ->(table) { table.ref_protected }.

  • 세 번째 인수는 배치 크기입니다(BATCH_SIZE 상수에 기본값이 설정됨): of: BATCH_SIZE.

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

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

마이그레이션에서 애플리케이션 코드(모델 포함)를 사용하는 것은 일반적으로 권장하지 않습니다. 이는 마이그레이션이 오랫동안 유지되고, 의존하는 애플리케이션 코드가 변경되어 향후 마이그레이션을 깨뜨릴 수 있기 때문입니다. 과거에 일부 백그라운드 마이그레이션은 여러 파일에 걸쳐 수백 줄의 코드를 마이그레이션에 복사하는 것을 피하기 위해 애플리케이션 코드를 사용해야 했습니다. 이러한 드문 경우에는 마이그레이션에 충분한 테스트가 있는지 확인하는 것이 매우 중요합니다. 그래야 향후 코드를 리팩토링하는 사람이 마이그레이션을 깨뜨렸는지 알 수 있습니다. 애플리케이션 코드 사용은 배치 백그라운드 마이그레이션에서도 권장하지 않으며 , 모델은 마이그레이션 내에서 선언해야 합니다.

일반적으로 MigrationRecord를 상속하는 클래스를 정의함으로써 마이그레이션에서 애플리케이션 코드(특히 모델)를 사용하지 않을 수 있습니다(아래 예시 참조).

모델을 사용하는 경우(마이그레이션 내에서 정의된 모델 포함), 먼저 reset_column_information을 사용하여 칼럼 캐시를 지워야 합니다.

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

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

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

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

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

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

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

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

기반 테이블이 수정된 후 ActiveRecord를 사용하여 접근합니다.

이는 동일한 db:migrate 프로세스에서 두 마이그레이션이 실행되는 경우, 이전의 다른 마이그레이션에서 테이블이 수정된 경우에도 사용해야 합니다.

결과는 다음과 같습니다. my_column이 포함된 것을 확인하세요:

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

스키마 캐시 지우기(User.reset_column_information)를 건너뛰면, 칼럼이 ActiveRecord에서 사용되지 않아 의도한 변경이 이루어지지 않습니다. 아래 결과에서 my_column이 쿼리에 누락된 것을 확인할 수 있습니다.

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

트래픽이 많은 테이블#

현재 고트래픽 테이블 목록을 확인할 수 있습니다.

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

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

현재 고트래픽 테이블과 비교하여 읽기 작업 수가 높은 테이블은 좋은 후보가 될 수 있습니다.

일반적인 원칙으로, GitLab.com의 분석 또는 보고 목적으로만 사용되는 칼럼을 고트래픽 테이블에 추가하는 것은 권장하지 않습니다. 이는 해당 기능의 직접적인 가치를 제공하지 않으면서도 모든 GitLab Self-Managed 인스턴스에 성능 부하를 줄 수 있습니다.

트리거 생성#

고트래픽 테이블에 트리거를 생성하면 배포 중 잠금 경쟁 타임아웃이 발생할 수 있습니다. 이를 완화하기 위해 with_lock_retries 헬퍼 메서드를 사용하는 포스트 배포 마이그레이션에서 트리거를 생성할 수 있습니다. 또한 마이그레이션이 중간에 실패하더라도 재시도할 수 있도록 멱등성을 보장해야 하며, 함수나 트리거가 이미 존재하더라도 실패하지 않아야 합니다.

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

  disable_ddl_transaction!

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

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

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

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

    drop_function(TRIGGER_FUNCTION_NAME, if_exists: true)
  end
end

with_lock_retries를 사용하려면 disable_ddl_transaction!이 필요합니다. create_trigger 헬퍼를 사용하여 트리거를 생성할 수 없는 경우(예: 트리거가 각 행이 아닌 각 구문에 대해 실행되는 경우), 트리거 생성 시 CREATE OR REPLACE TRIGGER를 사용하세요.

마일스톤#

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

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

  def change
    # Your migration here
  end
end

마이그레이션에 올바른 마일스톤을 추가하면 마이그레이션을 해당 GitLab 마이너 버전으로 논리적으로 분류할 수 있습니다. 이를 통해:

  • 업그레이드 프로세스가 단순화됩니다.

  • 마이그레이션의 타임스탬프만으로 순서를 결정할 때 발생할 수 있는 잠재적인 마이그레이션 순서 문제가 완화됩니다.

Autovacuum 래핑 방지#

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

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

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