Computer >> 컴퓨터 >  >> 프로그램 작성 >> Ruby

경쟁 조건을 방지하기 위해 ActiveRecords #update_counters 사용

Rails는 특정 상황을 위해 내장된 편리한 도구가 많이 포함된 대규모 프레임워크입니다. 이 시리즈에서는 Rails의 대규모 코드베이스에 숨겨져 있는 잘 알려지지 않은 도구를 살펴보겠습니다.

이 시리즈의 기사에서는 ActiveRecord의 update_counters를 살펴보겠습니다. 방법. 이 과정에서 우리는 다중 스레드 프로그램에서 "경합 조건"의 일반적인 함정과 이 방법이 이를 방지할 수 있는 방법을 살펴볼 것입니다.

스레드

프로그래밍할 때 프로세스, 스레드, 그리고 최근에는(Ruby에서) 파이버 및 리액터를 포함하여 코드를 병렬로 실행할 수 있는 여러 가지 방법이 있습니다. 이 기사에서는 스레드에 대해서만 걱정할 것입니다. 스레드는 Rails 개발자가 접하게 될 가장 일반적인 형식이기 때문입니다. 예를 들어 Puma는 다중 스레드 서버이고 Sidekiq는 다중 스레드 백그라운드 작업 프로세서입니다.

여기서는 스레드와 스레드 안전성에 대해 자세히 다루지 않을 것입니다. 알아야 할 주요 사항은 두 개의 스레드가 동일한 데이터에서 작동할 때 데이터가 쉽게 동기화되지 않을 수 있다는 것입니다. 이것이 "경합 조건"으로 알려져 있습니다.

경주 조건

경쟁 조건은 두 개(또는 그 이상)의 스레드가 동일한 데이터에서 동시에 작동할 때 발생합니다. 즉, 스레드가 오래된 데이터를 사용하게 될 수 있습니다. 두 스레드가 서로 경주하는 것과 같기 때문에 "경합 조건"이라고 하며, 어떤 스레드가 "경주에서 이겼는지"에 따라 데이터의 최종 상태가 다를 수 있습니다. 아마도 최악의 경우 경쟁 조건은 일반적으로 스레드가 특정 순서와 코드의 특정 지점에서 "교대로" 발생하는 경우에만 발생하기 때문에 재현하기가 매우 어렵습니다.

예시

경쟁 조건을 표시하는 데 사용되는 일반적인 시나리오는 은행 잔고를 업데이트하는 것입니다. 어떤 일이 일어나는지 볼 수 있도록 기본 Rails 애플리케이션 내에서 간단한 테스트 클래스를 만들 것입니다.

class UnsafeTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        balance = account.reload.balance
        account.update!(balance: balance + 100)

        balance = account.reload.balance
        account.update!(balance: balance - 100)
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

UnsafeTransaction 매우 간단합니다. Account를 조회하는 메소드가 하나만 있습니다. (BigDecimal balance가 있는 스톡 표준 Rails 모델 기인하다). 테스트를 더 쉽게 재실행할 수 있도록 잔액을 0으로 재설정했습니다.

내부 루프는 상황이 좀 더 흥미로워지는 곳입니다. 우리는 계정의 현재 잔고를 파악하고 여기에 100을 더한 다음(예:$100 예금) 100을 즉시 빼는(예:$100 인출) 4개의 스레드를 만들고 있습니다. reload도 사용 중입니다. 두 번 모두 추가 최신 잔액이 있는지 확인하십시오.

나머지 줄은 정리 중입니다. Thread.join 진행하기 전에 모든 스레드가 종료될 때까지 기다린 다음 메서드가 끝날 때 최종 잔액을 반환한다는 의미입니다.

단일 스레드로 이것을 실행했다면(루프를 1.times do로 변경하여 ), 우리는 행복하게 백만 번 실행할 수 있고 최종 계정 잔액이 항상 0이 되도록 할 수 있습니다. 그러나 두 개(또는 그 이상) 스레드로 변경하면 상황이 덜 확실해집니다.

콘솔에서 한 번 테스트를 실행하면 아마도 정답이 나올 것입니다.

UnsafeTransaction.run
=> 0.0

그러나 우리가 그것을 계속해서 실행한다면 어떨까요? 10번 실행했다고 가정해 보겠습니다.

(1..10).map { UnsafeTransaction.run }.map(&:to_f)
=> [0.0, 300.0, 300.0, 100.0, 100.0, 100.0, 300.0, 300.0, 100.0, 300.0]

여기의 구문이 익숙하지 않은 경우 (1..10).map {} 블록의 코드를 10번 실행하고 각 실행의 결과를 배열에 넣습니다. .map(&:to_f) 마지막에는 BigDecimal 값이 일반적으로 0.1e3과 같은 지수 표기법으로 인쇄되므로 사람이 더 읽기 쉽게 숫자를 만들 수 있습니다. .

기억하세요. 우리 코드는 현재 잔액을 가져와서 100을 더한 다음 즉시 100을 빼므로 최종 결과는 이어야 합니다. 항상 0.0이어야 합니다. . 이러한 100.0300.0 따라서 항목은 경쟁 조건이 있다는 증거입니다.

주석이 있는 예

여기에서 문제 코드를 확대하고 무슨 일이 일어나는지 봅시다. balance에 대한 변경 사항을 구분하겠습니다. 더 명확하게.

threads << Thread.new do
  # Thread could be switching here
  balance = account.reload.balance
  # or here...
  balance += 100
  # or here...
  account.update!(balance: balance)
  # or here...

  balance = account.reload.balance
  # or here...
  balance -= 100
  # or here...
  account.update!(balance: balance)
  # or here...
end

주석에서 볼 수 있듯이 이 코드의 거의 모든 시점에서 스레드가 스와핑될 수 있습니다. 스레드 1이 잔액을 읽으면 컴퓨터가 스레드 2를 실행하기 시작하므로 update!를 호출할 때 데이터가 최신이 아닐 가능성이 큽니다. . 즉, 스레드 1, 스레드 2 및 데이터베이스 모두에 데이터가 있지만 서로 동기화되지 않습니다.

여기 예제는 분석하기 쉽도록 의도적으로 사소한 것입니다. 그러나 실제 세계에서는 특히 일반적으로 안정적으로 재현할 수 없기 때문에 경쟁 조건을 진단하기가 더 어려울 수 있습니다.

솔루션

경합 상태를 방지하기 위한 몇 가지 옵션이 있지만 거의 대부분이 단일 아이디어를 중심으로 이루어집니다. 즉, 주어진 시간에 단 하나의 엔티티만 데이터를 변경하도록 하는 것입니다.

옵션 1:뮤텍스

가장 간단한 옵션은 일반적으로 뮤텍스로 알려진 "상호 배제 잠금"입니다. 뮤텍스는 키가 하나만 있는 잠금으로 생각할 수 있습니다. 한 스레드가 키를 보유하고 있으면 뮤텍스에 있는 모든 항목을 실행할 수 있습니다. 다른 모든 스레드는 키를 보유할 수 있을 때까지 기다려야 합니다.

예제 코드에 뮤텍스를 적용하면 다음과 같이 할 수 있습니다.

class MutexTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    mutex = Mutex.new

    threads = []
    4.times do
      threads << Thread.new do
        mutex.lock
        balance = account.reload.balance
        account.update!(balance: balance + 100)
        mutex.unlock

        mutex.lock
        balance = account.reload.balance
        account.update!(balance: balance - 100)
        mutex.unlock
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

여기서 account를 읽고 쓸 때마다 , 먼저 mutex.lock을 호출합니다. , 그리고 완료되면 mutex.unlock을 호출합니다. 다른 스레드가 회전할 수 있도록 합니다. mutex.lock을 호출하면 됩니다. 블록 시작 부분 및 mutex.unlock 끝에; 그러나 이것은 스레드가 더 이상 동시에 실행되지 않음을 의미하며, 이는 처음에 스레드를 사용하는 이유를 다소 부정합니다. 성능을 위해 mutex 내부에 코드를 유지하는 것이 가장 좋습니다. 스레드가 가능한 한 많은 코드를 병렬로 실행할 수 있으므로 가능한 한 작게 합니다.

.lock을 사용했습니다. 및 .unlock 명확성을 위해 Ruby의 Mutex 클래스는 멋진 synchronize를 제공합니다. 블록을 가져와 처리하는 메서드이므로 다음을 수행할 수 있습니다.

mutex.synchronize do
  balance = ...
  ...
end

Ruby의 Mutex는 우리가 필요로 하는 작업을 수행하지만 아마도 상상할 수 있듯이 Rails 애플리케이션에서 특정 데이터베이스 행을 잠글 필요가 있는 것은 매우 일반적이며 ActiveRecord는 이 시나리오를 다루었습니다.

옵션 2:ActiveRecord 잠금

ActiveRecord는 몇 가지 다른 잠금 메커니즘을 제공하며 여기에서 모두 자세히 다루지는 않겠습니다. 우리의 목적을 위해 lock!을 사용할 수 있습니다. 업데이트하려는 행을 잠그려면:

class LockedTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        Account.transaction do
          account = account.reload
          account.lock!
          account.update!(balance: account.balance + 100)
        end

        Account.transaction do
          account = account.reload
          account.lock!
          account.update!(balance: account.balance - 100)
        end
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

Mutex가 특정 스레드에 대한 코드 섹션을 "잠그는" 반면, lock! 특정 데이터베이스 행을 잠급니다. 이는 동일한 코드가 여러 계정에서 병렬로 실행될 수 있음을 의미합니다(예:많은 백그라운드 작업에서). 동일한 레코드에 액세스해야 하는 스레드만 기다려야 합니다. ActiveRecord는 편리한 #with_lock도 제공합니다. 트랜잭션을 수행하고 한 번에 잠글 수 있는 메서드이므로 위의 업데이트를 다음과 같이 좀 더 간결하게 작성할 수 있습니다.

account = account.reload
account.with_lock do
  account.update!(account.balance + 100)
end
...

해결책 3:원자적 방법

'원자적' 메서드(또는 함수)는 실행 도중에 중지할 수 없습니다. 예를 들어, 일반적인 += Ruby에서의 작업은 아닙니다. 단일 작업처럼 보이지만 원자적:

value += 10

# equivalent to:
value = value + 10

# Or even more verbose:
temp_value = value + 10
value = temp_value

스레드가 value + 10을 계산하는 사이에 갑자기 "잠자기" 상태가 되면 is이고 결과를 다시 value에 씁니다. , 경쟁 조건의 가능성을 엽니다. 그러나 Ruby가 이 작업 동안 스레드를 잠자기 상태로 두지 않았다고 가정해 보겠습니다. 이 작업 동안 스레드가 절대 절전 모드로 전환되지 않는다고 확실히 말할 수 있다면(예:컴퓨터는 실행을 다른 스레드로 전환하지 않을 것입니다) "원자적" 작업으로 간주될 수 있습니다.

일부 언어에는 정확히 이러한 종류의 스레드 안전성을 위한 기본 값의 원자 버전이 있습니다(예:AtomicInteger 및 AtomicFloat). 그렇다고 Rails 개발자로서 사용할 수 있는 "원자적" 작업이 몇 가지 없다는 의미는 아닙니다. 한 번 예는 ActiveRecord의 update_counters입니다. 방법.

이것은 카운터 캐시를 최신 상태로 유지하기 위한 것이지만 애플리케이션에서 사용하는 것을 막지는 못합니다. 카운터 캐시에 대한 자세한 내용은 캐싱에 대한 이전 기사를 참조하십시오.

이 방법을 사용하는 것은 매우 간단합니다.

class CounterTransaction
  def self.run
    account = Account.find(1)
    account.update!(balance: 0)

    threads = []
    4.times do
      threads << Thread.new do
        Account.update_counters(account.id, balance: 100)

        Account.update_counters(account.id, balance: -100)
      end
    end

    threads.map(&:join)

    account.reload.balance
  end
end

뮤텍스도 없고 잠금도 없고 Ruby 두 줄만 있으면 됩니다. update_counters 레코드 ID를 첫 번째 인수로 취한 다음 변경할 열을 알려줍니다(balance: ) 및 얼마나 변경할지 (100 또는 -100 ). 이것이 작동하는 이유는 읽기-업데이트-쓰기 주기가 단일 SQL 호출로 데이터베이스에서 발생하기 때문입니다. 이것은 Ruby 스레드가 작업을 중단할 수 없음을 의미합니다. 데이터베이스가 실제 계산을 하고 있기 때문에 잠자기 상태가 되어도 문제가 되지 않습니다.

생성되는 실제 SQL은 다음과 같이 나옵니다(적어도 내 컴퓨터의 postgres의 경우).

Account Update All (1.7ms)  UPDATE "accounts" SET "balance" = COALESCE("balance", 0) + $1 WHERE "accounts"."id" = $2  [["balance", "100.0"], ["id", 1]]

이 방법은 또한 계산이 데이터베이스에서 완전히 이루어지기 때문에 훨씬 더 나은 성능을 제공합니다. reload할 필요가 없습니다. 최신 값을 가져오는 레코드입니다. 하지만 이 속도에는 대가가 따릅니다. 원시 SQL에서 이 작업을 수행하기 때문에 Rails 모델을 우회합니다. 즉, 유효성 검사 또는 콜백이 실행되지 않습니다(즉, 무엇보다도 updated_at 타임스탬프).

결론

경쟁 조건은 Heisenbug 포스터 자식이 될 수 있습니다. 그것들은 들여오기 쉽고, 종종 번식이 불가능하고, 예측하기 어렵습니다. Ruby와 Rails는 최소한 이러한 문제를 발견하면 해결할 수 있는 몇 가지 유용한 도구를 제공합니다.

일반 Ruby 코드의 경우 Mutex 이것은 좋은 옵션이며 아마도 대부분의 개발자가 "스레드 안전성"이라는 용어를 들었을 때 가장 먼저 생각하는 것입니다.

Rails를 사용하면 데이터가 ActiveRecord에서 올 가능성이 높습니다. 이러한 경우 lock! (또는 with_lock )는 사용하기 쉽고 데이터베이스의 관련 행만 잠그므로 뮤텍스보다 더 많은 처리량을 허용합니다.

솔직히 말하겠습니다. update_counters에 도달할지 확신이 서지 않습니다. 현실 세계에서 많이. 다른 개발자가 작동 방식에 익숙하지 않을 수 있을 만큼 흔하지 않으며 코드의 의도가 특히 명확하지 않습니다. 스레드 안전 문제에 직면하면 ActiveRecord의 잠금(lock! 또는 with_lock )는 더 일반적이고 코더의 의도를 더 명확하게 전달합니다.

그러나 백업하는 간단한 '추가 또는 빼기' 작업이 많고 실제 속도가 필요한 경우 update_counters 뒷주머니에 유용한 도구가 될 수 있습니다.