SQL 쿼리 지침

이 문서는 SQL 쿼리를 작성할 때 따를 여러 지침에 대해 설명합니다. ActiveRecord/Arel이나 원시 SQL 쿼리를 사용하는 경우 모두 해당됩니다.

LIKE 문 사용

데이터를 검색하는 가장 일반적인 방법은 LIKE 문을 사용하는 것입니다. 예를 들어, 제목이 “Draft:”로 시작하는 모든 이슈를 가져오려면 다음 쿼리를 작성합니다.

SELECT *
FROM issues
WHERE title LIKE 'Draft:%';

PostgreSQL에서 LIKE 문은 대소문자를 구분합니다. 대소문자를 구분하지 않으려면 ILIKE를 사용해야 합니다.

이를 자동으로 처리하려면 PostgreSQL에서 ILIKE를 자동으로 사용하는 Arel을 사용하여 LIKE 쿼리를 사용해야 합니다.

Issue.where('title LIKE ?', 'Draft:%')

대신 다음과 같이 작성해야 합니다.

Issue.where(Issue.arel_table[:title].matches('Draft:%'))

여기서 matches는 사용 중인 데이터베이스에 따라 올바른 LIKE / ILIKE 문을 생성합니다.

여러 OR 조건을 연결해야 하는 경우 Arel을 사용하여 다음과 같이 수행할 수도 있습니다.

table = Issue.arel_table

Issue.where(table[:title].matches('Draft:%').or(table[:foo].matches('Draft:%')))

PostgreSQL에서는 다음과 같이 나타납니다.

SELECT *
FROM issues
WHERE (title ILIKE 'Draft:%' OR foo ILIKE 'Draft:%')

LIKE 및 인덱스

PostgreSQL은 시작 부분에 와일드카드가 있는 LIKE / ILIKE를 사용할 때 어떤 인덱스도 사용하지 않습니다. 예를 들어, 다음은 어떤 인덱스도 사용하지 않습니다.

SELECT *
FROM issues
WHERE title ILIKE '%Draft:%';

ILIKE의 값이 와일드카드로 시작하기 때문에 데이터베이스는 인덱스를 스캔을 시작할 위치를 모르기 때문에 인덱스를 사용할 수 없습니다.

다행히도 PostgreSQL은 해결책을 제공합니다: trigram 일반화된 역 인덱스(GIN) 인덱스입니다. 이러한 인덱스는 다음과 같이 생성할 수 있습니다.

CREATE INDEX [CONCURRENTLY] index_name_here
ON table_name
USING GIN(column_name gin_trgm_ops);

이 중요한 부분은 GIN(column_name gin_trgm_ops)입니다. 이렇게 하면 gin_trgm_ops로 설정된 연산자 클래스를 사용한 GIN 인덱스가 생성됩니다. 이러한 인덱스는 ILIKE / LIKE를 사용할 수 있으며 성능을 크게 향상시킬 수 있습니다. 이러한 인덱스의 단점 중 하나는 데이터가 많을수록 인덱스가 상당히 크게 될 수 있다는 것입니다.

이러한 인덱스의 이름을 일관되게 유지하기 위해 다음과 같은 이름 패턴을 사용하세요.

index_TABLE_on_COLUMN_trigram

예를 들어 issues.title의 GIN/trigram 인덱스는 index_issues_on_title_trigram라고 할 수 있습니다.

이러한 인덱스가 생성되는 데 상당한 시간이 걸리기 때문에 동시에 생성되어야 합니다. 이는 CREATE INDEX 대신 CREATE INDEX CONCURRENTLY를 사용하여 수행할 수 있습니다. 동시 인덱스는 트랜잭션 내에서 생성할 수 없습니다. 데이터베이스 마이그레이션의 트랜잭션은 다음과 같이 사용하여 비활성화할 수 있습니다.

class MigrationName < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!
end

예를 들어:

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

  def up
    execute 'CREATE INDEX CONCURRENTLY index_on_users_lower_username ON users (LOWER(username));'
    execute 'CREATE INDEX CONCURRENTLY index_on_users_lower_email ON users (LOWER(email));'
  end

  def down
    remove_index :users, :index_on_users_lower_username
    remove_index :users, :index_on_users_lower_email
  end
end

데이터베이스 열을 신뢰할 수 있게 참조하기

기본적으로 ActiveRecord는 쿼리한 데이터베이스 테이블에서 모든 열을 반환합니다. 경우에 따라 반환된 행을 사용자 정의해야 하는 경우가 있습니다. 예를 들어:

  • 데이터베이스로부터 반환되는 데이터 양을 줄이기 위해 몇 개의 열만 지정합니다.
  • JOIN 관계에서 열 포함
  • 계산 수행(SUM, COUNT)

이 예에서 열을 지정하지만 테이블은 지정하지 않습니다.

  • projects 테이블에서 path
  • merge_requests 테이블에서 user_id

쿼리:

# 나쁨, 피하세요
Project.select("path, user_id").joins(:merge_requests) # SELECT path, user_id FROM "projects" ...

나중에 새로운 기능이 projects 테이블에 추가로 열을 추가합니다: user_id. 배포 중에 데이터베이스 마이그레이션이 이미 실행되었지만 새 버전의 응용 프로그램 코드가 아직 배포되지 않은 잠깐의 시간 창이 있을 수 있습니다. 위의 쿼리가 이 기간에 실행되면 다음과 같은 오류 메시지가 표시됩니다: PG::AmbiguousColumn: ERROR: column reference "user_id" is ambiguous

이 문제는 데이터베이스에서 속성이 선택된 방식 때문에 발생합니다. user_id 열은 users 테이블과 merge_requests 테이블에 모두 존재합니다. 쿼리 플래너는 user_id 열을 조회할 때 어떤 테이블을 사용할지 결정할 수 없습니다.

사용자 정의 SELECT 문을 작성할 때는 테이블 이름을 명시적으로 지정하는 것이 좋습니다.

Good (선호)

Project.select(:path, 'merge_requests.user_id').joins(:merge_requests)

# SELECT "projects"."path", merge_requests.user_id as user_id FROM "projects" ...
Project.select(:path, :'merge_requests.user_id').joins(:merge_requests)

# SELECT "projects"."path", "merge_requests"."id" as user_id FROM "projects" ...

Arel을 사용한 예시 (arel_table):

Project.select(:path, MergeRequest.arel_table[:user_id]).joins(:merge_requests)

# SELECT "projects"."path", "merge_requests"."user_id" FROM "projects" ...

원시 SQL 쿼리 작성 시:

SELECT projects.path, merge_requests.user_id FROM "projects"...

원시 SQL 쿼리가 매개변수화된 경우 (이스케이핑 필요):

include ActiveRecord::ConnectionAdapters::Quoting

"""
SELECT
  #{quote_table_name('projects')}.#{quote_column_name('path')},
  #{quote_table_name('merge_requests')}.#{quote_column_name('user_id')}
FROM ...
"""

Bad (피하기)

Project.select('id, path, user_id').joins(:merge_requests).to_sql

# SELECT id, path, user_id FROM "projects" ...
Project.select("path", "user_id").joins(:merge_requests)
# SELECT "projects"."path", "user_id" FROM "projects" ...

# 혹은

Project.select(:path, :user_id).joins(:merge_requests)
# SELECT "projects"."path", "user_id" FROM "projects" ...

컬럼 목록이 주어진 경우, ActiveRecord는 인수를 projects 테이블에 정의된 열과 자동으로 일치시키려고 합니다. 이 경우 id 열은 문제가 되지 않지만, user_id 열은 예상치 못한 데이터를 반환할 수 있습니다:

Project.select(:id, :user_id).joins(:merge_requests)

# 배포 전 (user_id는 merge_requests 테이블에서 가져옵니다):
# SELECT "projects"."id", "user_id" FROM "projects" ...

# 배포 후 (user_id는 projects 테이블에서 가져옵니다):
# SELECT "projects"."id", "projects"."user_id" FROM "projects" ...

ID 추출(plucking)

pluck를 사용하여 값을 메모리로 추출하여 다른 쿼리의 인수로 사용해서는 안됩니다. 예를 들어, 이는 추가적인 불필요한 데이터베이스 쿼리를 실행하고 많은 불필요한 데이터를 메모리로 로드합니다:

projects = Project.all.pluck(:id)

MergeRequest.where(source_project_id: projects)

대신에 훨씬 더 성능이 우수한 서브쿼리를 사용할 수 있습니다:

MergeRequest.where(source_project_id: Project.all.select(:id))

실제로 Ruby에서 값을 조작해야 하는 경우에만 pluck를 사용해야 합니다(예: 파일에 쓰기). 거의 모든 다른 경우에는 스스로 “서브쿼리를 사용할 수 없는가?”라고 물어보아야 합니다.

CodeReuse/ActiveRecord 루비콥에 따라, 모델 코드 내에서만 pluck(:id) 또는 pluck(:user_id)와 같은 형태를 사용해야 합니다. 전자의 경우, ApplicationRecord에서 제공하는 .pluck_primary_key 도우미 메서드를 대신 사용할 수 있습니다. 후자의 경우 해당 모델에 작은 도우미 메서드를 추가해야 합니다.

만일 pluck를 사용할 강력한 이유가 있다면, 추출되는 레코드의 수를 제한하는 것이 합리적일 수 있습니다. MAX_PLUCKApplicationRecord에서 기본적으로 1,000로 설정됩니다.

ApplicationRecord로부터 상속받기

GitLab 코드베이스의 대부분의 모델은 ApplicationRecord 또는 Ci::ApplicationRecord로부터 상속받아야 합니다. 이를 통해 도우미 메서드를 쉽게 추가할 수 있습니다.

이 규칙의 예외는 데이터베이스 마이그레이션에서 생성된 모델에 대해 존재합니다. 애플리케이션 코드로부터 격리되어야 하기 때문에, 이러한 모델들은 마이그레이션 컨텍스트에서만 사용 가능한 MigrationRecord로부터 계속해서 하위 클래스화해야 합니다.

UNION 사용하기

대부분의 Rails 애플리케이션에서 UNION은 그리 흔하게 사용되지 않지만, 매우 강력하고 유용합니다. 쿼리는 관련 데이터 또는 특정 기준에 따라 데이터를 얻기 위해 많은 JOIN을 사용하기 마련이지만, JOIN 성능은 관련 데이터가 커짐에 따라 빠르게 저하될 수 있습니다.

예를 들어, 이름에 특정 값을 포함하는 프로젝트 목록을 얻고자 한다면, 대부분의 사람들은 다음과 같은 쿼리를 작성할 것입니다:

SELECT *
FROM projects
JOIN namespaces ON namespaces.id = projects.namespace_id
WHERE projects.name ILIKE '%gitlab%'
OR namespaces.name ILIKE '%gitlab%';

이 큰 데이터베이스를 사용하는 경우, 이 쿼리는 보통 800밀리초 내외로 실행될 수 있습니다. UNION을 사용하면 다음과 같이 작성할 수 있습니다:

SELECT projects.*
FROM projects
WHERE projects.name ILIKE '%gitlab%'

UNION

SELECT projects.*
FROM projects
JOIN namespaces ON namespaces.id = projects.namespace_id
WHERE namespaces.name ILIKE '%gitlab%';

이 쿼리는 동일한 레코드를 반환하면서 약 15밀리초 정도만에 완료될 수 있습니다.

이는 모든 곳에서 UNION을 사용해야 한다는 뜻이 아니지만, 많은 JOIN과 쿼리에 기반을 둔 레코드 필터링 시 고려할만한 사항입니다.

GitLab에는 여러 개의 ActiveRecord::Relation 객체를 UNION하는 Gitlab::SQL::Union 클래스가 함께 제공됩니다. 이 클래스는 다음과 같이 사용할 수 있습니다:

union = Gitlab::SQL::Union.new([projects, more_projects, ...])

Project.from("(#{union.to_sql}) projects")

FromUnion 모델 concern은 위와 같은 결과를 더 편리하게 얻기 위한 보다 편리한 방법을 제공합니다:

class Project
  include FromUnion
  ...
end

Project.from_union(projects, more_projects, ...)

UNION은 코드베이스 전역에서 흔하지만, EXCEPTINTERSECT와 같은 다른 SQL 세트 연산자를 사용할 수도 있습니다:

class Project
  include FromIntersect
  include FromExcept
  ...
end

intersected = Project.from_intersect(all_projects, project_set_1, project_set_2)
excepted = Project.from_except(all_projects, project_set_1, project_set_2)

UNION 서브쿼리의 열이 불균형일 때

UNION 쿼리의 SELECT 절에 열이 불균형하면 데이터베이스에서 오류가 발생합니다. 다음과 같은 UNION 쿼리를 고려해보세요:

SELECT id FROM users WHERE id = 1
UNION
SELECT id, name FROM users WHERE id = 2
end

이 쿼리는 다음과 같은 오류 메시지를 반환합니다:

each UNION query must have the same number of columns

문제점은 명백하며 개발 중에 쉽게 해결할 수 있습니다. 한 가지 특별한 경우는 UNION 쿼리가 ActiveRecord 스키마 캐시에서 가져온 명시적 열 목록과 결합된 경우입니다.

나쁜 예 (피해야 함):

scope1 = User.select(User.column_names).where(id: [1, 2, 3]) # 열을 명시적으로 선택
scope2 = User.where(id: [10, 11, 12]) # SELECT users.*를 사용함

User.connection.execute(Gitlab::SQL::Union.new([scope1, scope2]).to_sql)

이 코드가 배포되면 즉시 문제를 일으키지는 않습니다. 그러나 다른 개발자가 users 테이블에 새로운 데이터베이스 열을 추가하면 이 쿼리는 프로덕션 환경에서 실패하고 다운타임을 발생시킬 수 있습니다. 두 번째 쿼리(SELECT users.*)에는 새로 추가된 열이 포함되지만 첫 번째 쿼리에는 포함되어 있지 않습니다. column_names 메서드는 캐시된 값(새 열이 누락됨)을 반환하므로 ActiveRecord 스키마 캐시 내에 캐시됩니다. 이 값은 일반적으로 응용 프로그램 부팅 시에 채워집니다.

이 시점에서 유일한 해결책은 스키마 캐시가 업데이트되도록 전체 응용 프로그램을 다시 시작하는 것입니다. GitLab 16.1 이후로 스키마 캐시는 자동으로 재설정되어 후속 쿼리가 성공하도록 할 수 있습니다. 이 재설정은 ops 기능 플래그 reset_column_information_on_statement_invalid를 비활성화하여 비활성화할 수 있습니다.

이 문제는 항상 SELECT users.*를 사용하거나 항상 열을 명시적으로 정의하는 경우에 피할 수 있습니다.

SELECT users.*를 사용하는 경우:

# 나쁨, 피해야 함
scope1 = User.select(User.column_names).where(id: [1, 2, 3])
scope2 = User.where(id: [10, 11, 12])

# 좋음, 두 쿼리 모두 SELECT users.*를 생성함
scope1 = User.where(id: [1, 2, 3])
scope2 = User.where(id: [10, 11, 12])

User.connection.execute(Gitlab::SQL::Union.new([scope1, scope2]).to_sql)

명시적 열 목록 정의:

# 좋음, SELECT 열이 일관적임
columns = User.cached_column_list # 헬퍼는 완전히 자격이 부여된(table.column) 열 이름(Arel)을 반환함
scope1 = User.select(*columns).where(id: [1, 2, 3]) # 열을 명시적으로 선택
scope2 = User.select(*columns).where(id: [10, 11, 12]) # SELECT users.*를 사용함

User.connection.execute(Gitlab::SQL::Union.new([scope1, scope2]).to_sql)

생성 날짜별로 정렬하기

레코드를 생성된 시간에 따라 정렬할 때는 created_at에 대신 id 열로 정렬할 수 있습니다. ID는 항상 고유하며 행이 생성된 순서대로 증가하기 때문에 동일한 결과를 얻습니다. 이는 created_at에 일관된 성능을 위해 인덱스를 추가할 필요가 없다는 것을 의미합니다.

WHERE EXISTS 대신 WHERE IN 사용

WHERE INWHERE EXISTS는 동일한 데이터를 생성할 수 있지만 가능하면 WHERE EXISTS를 사용하는 것이 좋습니다. 많은 경우 PostgreSQL은 WHERE IN을 효율적으로 최적화할 수 있지만 WHERE EXISTS가 (훨씬) 더 효율적인 경우가 많습니다.

Rails에서는 SQL 프래그먼트를 생성하여 사용해야 합니다:

Project.where('EXISTS (?)', User.select(1).where('projects.creator_id = users.id AND users.foo = X'))

그러면 다음과 유사한 쿼리가 생성됩니다:

SELECT *
FROM projects
WHERE EXISTS (
    SELECT 1
    FROM users
    WHERE projects.creator_id = users.id
    AND users.foo = X
)

.find_or_create_by는 원자적이지 않음

.find_or_create_by.first_or_create와 같은 메서드의 내재된 패턴은 원자적이지 않습니다. 즉, 먼저 SELECT를 실행하고 결과가 없는 경우 INSERT가 수행됩니다. 동시 프로세스를 고려할 때 경쟁 상태가 발생하여 유사한 레코드를 두 번 삽입하려고 시도할 수 있습니다. 이는 원하지 않을 수 있거나, 제약 조건 위반으로 인해 쿼리 중 하나가 실패할 수 있습니다.

이 문제를 해결하기 위해 ApplicationRecord.safe_find_or_create_by를 추가했습니다.

이 메서드는 find_or_create_by와 동일하게 사용할 수 있지만 새로운 트랜잭션(또는 서브 트랜잭션)으로 호출을 래핑하고, ActiveRecord::RecordNotUnique 오류로 실패할 경우 재시도합니다.

이 메서드를 사용하려면 해당 모델이 ApplicationRecord에서 상속되었는지 확인하세요.

Rails 6 이후에는 .create_or_find_by 메서드가 있습니다. 이 메서드는 우리의 .safe_find_or_create_by 메서드와 다릅니다. 왜냐하면 호출이 실패할 경우 INSERT를 수행하고 그런 후 SELECT 명령을 수행합니다.

INSERT가 실패하면 데드 투플을 남기고 기본 키 시퀀스를 증가시키며 기타 단점이 있습니다.

만약 일반적인 경로가 처음으로 레코드를 생성한 후에 해당 레코드를 재사용하는 경우에는 .safe_find_or_create_by를 선호합니다. 그러나 더 흔한 경로가 새 레코드를 만들고, 에지 케이스(예: 작업 재시도)에서만 중복 레코드가 삽입되는 것을 피하고 싶다면 .create_or_find_bySELECT를 저장할 수 있습니다.

두 메서드 모두 기존 트랜잭션의 컨텍스트 내에서 실행될 경우 내부적으로 서브 트랜잭션을 사용합니다. 이는 단일 트랜잭션 내에서 64개 이상의 라이브 서브 트랜잭션이 사용될 때 전반적인 성능에 중대한 영향을 미칠 수 있습니다.

.safe_find_or_create_by를 사용할 수 있나요?

일반적으로 코드가 격리되어 있고(예: 워커에서만 실행되는 경우) 다른 트랜잭션으로 감싸이지 않는 경우에는 .safe_find_or_create_by를 사용할 수 있습니다. 그러나 다른 사람이 트랜잭션 내에서 코드를 호출할 경우 케이스를 잡아내는 도구가 없습니다. .safe_find_or_create_by를 사용하면 현재 완전히 제거할 수 없는 몇 가지 위험이 있을 수 있습니다.

게다가, 우리에게는 .safe_find_or_create_by의 사용을 방지하는 RuboCop 규칙 Performance/ActiveRecordSubtransactionMethods가 있습니다. 이 규칙은 # rubocop:disable Performance/ActiveRecordSubtransactionMethods를 통해 경우에 따라 비활성화할 수 있습니다.

대안 1: UPSERT

.upsert 메서드는 table이 고유한 인덱스로 백업되는 경우 대체 솔루션일 수 있습니다.

.upsert 메서드의 간단한 사용법:

BuildTrace.upsert(
  {
    build_id: build_id,
    title: title
  },
  unique_by: :build_id
)

주의할 점 몇 가지:

  • 기본 키의 순서는 증가됩니다. 심지어 레코드가 업데이트되었을 때도 마찬가지입니다.
  • 생성된 레코드가 반환되지 않습니다. returning 옵션은 INSERT가 발생할 때(새로운 레코드)에만 데이터를 반환합니다.
  • ActiveRecord 유효성 검사가 실행되지 않습니다.

유효성 검사와 레코드 로딩이 포함된 .upsert 메서드의 예:

params = {
  build_id: build_id,
  title: title
}

build_trace = BuildTrace.new(params)

unless build_trace.valid?
  raise '여기서 사용자에게 알리세요'
end

BuildTrace.upsert(params, unique_by: :build_id)

build_trace = BuildTrace.find_by!(build_id: build_id)

# build_trace를 여기서 사용하세요

위의 코드 스니펫은 build_id 열에 모델 수준 유일성 유효성 검사가 있는 경우 잘 작동하지 않습니다. upsert를 호출하기 전에 유효성 검사를 호출하기 때문입니다.

이를 해결하기 위해 두 가지 옵션이 있습니다:

  • ActiveRecord 모델에서 유일성 유효성 검사를 제거합니다.
  • on 키워드를 사용하고 컨텍스트별 유효성 검사를 구현합니다.

대안 2: 존재 여부 확인 및 예외 처리

동시에 동일한 레코드를 생성할 가능성이 매우 낮은 경우 더 간단한 방법을 사용할 수 있습니다:

def my_create_method
  params = {
    build_id: build_id,
    title: title
  }

  build_trace = BuildTrace
    .where(build_id: params[:build_id])
    .first

  build_trace = BuildTrace.new(params) if build_trace.blank?

  build_trace.update!(params)

rescue ActiveRecord::RecordInvalid => invalid
  retry if invalid.record&.errors&.of_kind?(:build_id, :taken)
end

이 메서드는 다음과 같은 작업을 수행합니다:

  1. 고유한 열에 의해 모델을 찾습니다.
  2. 레코드가 없으면 새로운 레코드를 빌드합니다.
  3. 레코드를 유지합니다.

조회 쿼리와 지속 쿼리 사이에 짧은 레이스 조건이 있으며, 다른 프로세스가 레코드를 삽입하고 ActiveRecord::RecordInvalid 예외를 발생시킬 수 있습니다.

이 코드는 특정 예외를 처리하고 작업을 다시 시도합니다. 두 번째 실행에는 레코드가 성공적으로 찾아질 것입니다. 예를들어 PreventApprovalByAuthorService이 블록 코드를 확인하세요.

운영 환경에서 SQL 쿼리 모니터링

GitLab 팀 멤버는 PostgreSQL 로그를 사용하여 GitLab.com에서 느린 쿼리나 취소된 쿼리를 모니터링할 수 있으며, 이 로그는 Elasticsearch에 인덱싱되어 Kibana를 통해 검색할 수 있습니다.

더 자세한 내용은 런북을 참조하세요.

공통 테이블 표현식 사용 시기

공통 테이블 표현식(CTE)을 사용하여 더 복잡한 쿼리 내에서 임시 결과 집합을 생성할 수 있습니다. 또한 재귀 CTE를 사용하여 쿼리 자체에서 CTE의 결과 집합을 참조할 수 있습니다. 다음 예제는 previous_personal_access_token_id 열에서 서로 참조하는 개인 액세스 토큰의 체인을 쿼리하는 것입니다.

WITH RECURSIVE "personal_access_tokens_cte" AS (
(
    SELECT
      "personal_access_tokens".*
    FROM
      "personal_access_tokens"
    WHERE
      "personal_access_tokens"."previous_personal_access_token_id" = 15)
  UNION (
    SELECT
      "personal_access_tokens".*
    FROM
      "personal_access_tokens",
      "personal_access_tokens_cte"
    WHERE
      "personal_access_tokens"."previous_personal_access_token_id" = "personal_access_tokens_cte"."id"))
SELECT
  "personal_access_tokens".*
FROM
  "personal_access_tokens_cte" AS "personal_access_tokens"

 id | previous_personal_access_token_id
----+-----------------------------------
 16 |                                15
 17 |                                16
 18 |                                17
 19 |                                18
 20 |                                19
 21 |                                20
(6 rows)

CTE가 임시 결과 집합이기 때문에 다른 SELECT 문 내에서 사용할 수 있습니다. CTEUPDATE 또는 DELETE와 함께 사용하려면 예상치 못한 동작을 일으킬 수 있습니다:

다음 방법을 고려하십시오:

  1. 레코드의 ids를 조회합니다:

    > token_ids = personal_access_token_chain(token).pluck_primary_key
    => [16, 17, 18, 19, 20, 21]
    
  2. 이 배열을 사용하여 PersonalAccessTokens를 스코핑합니다:

    PersonalAccessToken.where(id: token_ids).update_all(revoked: true)
    

또는 이 두 단계를 결합하세요:

PersonalAccessToken
  .where(id: personal_access_token_chain(token).pluck_primary_key)
  .update_all(revoked: true)

참고: 대량의 언바운드 데이터를 업데이트하는 것을 피하십시오. 데이터에 응용 프로그램 제한이 없거나 데이터 양을 모르는 경우, 데이터를 일괄로 업데이트해야 합니다.