대량 업데이트: 활성 레코드 및 네이티브 SQL

약 150.000개의 기록에서 실행되는 동적 대량 업데이트의 세 가지 다른 방법을 비교했다.


다음 예제에서는 Active Recovery 및 원래 SQL 방식으로 해결된 대량 작업 문제를 복제합니다.나는 세 가지 다른 해결 방안을 소개하고 그것들의 성능을 비교할 것이다.모든 해결 방안에 대해 저는 코드를 공유하고 제 기계(개발 환경)에서 코드를 운행하며 해결 방안마다 약 15만 개의 기록을 성공적으로 갱신하는 데 소요되는 시간을 등록할 것입니다.
그리고 다음db 모델과 모델을 고려해 보겠습니다.

class PlayerGameStatistic < ApplicationRecord
  belongs_to :player
  belongs_to :team
  belongs_to :game
end
class Player < ApplicationRecord
  belongs_to :team
  has_many :player_game_statistics

  enum status: %i[active inactive retired]
end
class Team < ApplicationRecord
  belongs_to :team
  has_many :player_game_statistics
  has_many :games

  enum status: %i[inactive active]
end
우리는 세 개의 탁자가 있다. players, teams, player_game_statistics.나는 이 연습과 무관하기 때문에 이곳의 게임스 시계(player_game_statistics도 이 시계에 속한다)를 배제하고 싶다.
그것들은 모두 장기적으로 존재하는 표이다.그것들은 모두 수천 줄을 가지고 있지만, 그 성질로 말하자면, player_game_statistics은 더 많은 줄을 싣는다.
지금 내가 새로운 열 player_name에서 player_game_statistics을 추가했다고 가정해 보자.이 열이 비어 있는 (기본값은 null) 의 기존 기록에 대해, 나는 name 표의 players 열에서 사용할 수 있는 데이터로 그것을 채우고 싶다.
행수를 조금 줄이기 위해 현역과 부상 선수(은퇴 선수 포함)와 현역 팀(선수와 팀 모두 컨디션 속성이 있는 값)의 통계 정보를 갱신하는 데만 흥미를 느낀다.

버전 1: 활동 로깅 방법


첫 번째 방법은 각각 player_game_statistic을 교체하여 관련 player열을 얻고 player_name치를 바탕으로 통계 데이터의 player.name열을 업데이트하는 것이다.
이것은 고전적인 n + 1 조회를 초래할 것이다.우리는 player_game_statistics_to_update을 불러오고 n의 추가 조회를 실행하여 모든 유저의 이름을 얻을 것입니다.
이것이 바로 :includes이 통상적으로 나타나는 곳이다.보충해 봅시다.
class UpdatePlayerGameStatisticsV1
  def execute!
    player_game_statistics_to_update.each do |stat|
      stat.update!(player_name: stat.player.name)
    end
  end

  private

  def user_statistics_to_update
    PlayerGameStatistic
      .includes(:player)
      .joins(:team)
      .references(:player)
      .where('team.status = 1')
      .where('player.status IN (0, 1)')
  end
end
:includes:preload 또는 :eager_load을 사용합니다. 이것은 하위 조회 (예:where 자구) 에 불러오는 관련을 인용하는지 여부에 달려 있습니다.우리의 예에서 우리는 이 점을 하기를 희망한다. 이렇게 하면 우리는 유저 상태에 따라 결과를 필터할 수 있다.이로써 player_game_statistics에서 단일 조회를 하고 players에서 왼쪽 외부 연결을 하고 teams에서 내부 연결을 하게 된다.:eager_load:preload보다 느리지만 우리가 있는 이곳은 선택이 없고 :includes을 전혀 사용하지 않는 것보다 낫다.:includes이 어떻게 작동하는지에 대한 더 자세한 정보는 great article by Julianna Roen on the gusto blog을 사용하는 것을 권장합니다.
결과: 약 00:04:33에 약 150.000줄이 업데이트되었습니다.

버전 2: 기억 사전이 있는 활동 기록


내가 흥미를 느끼는 두 번째 방법은 사전 캐시 방법이다. 이런 방법에서 모든 참여자를 조회하고 player_idplayer_name을 추출하여 결과를 산열로 바꾸어 기억한다.
우리는 여전히 모든 통계 데이터를 교체하지만, 게이머들이 통계 데이터를 통해 게이머를 한 번 조회하게 하는 것은 아니다. 다음 교체는 사전만 조회할 수 있다.버전 1과 비교하면, 이런 방법은 추가 조회를 만들 수 있지만, 나는 그것이 그렇게 무겁지 않을 것이라고 가정한다. 왜냐하면 내가 사용하는 것은 :pluck이기 때문이다.
class UpdatePlayerGameStatisticsV2
  def execute!
    player_game_statistics_to_update.each do |stat|
      stat.update!(player_name: player_name_idx[stat.player_id])
    end
  end

  private

  def player_game_statistics_to_update
    PlayerGameStatistic.where(player_id: player_name_idx.keys)
  end

  def player_name_idx
    @player_name_idx ||=
      Player
        .joins(:team)
        .where('teams.status = 1')
        .where("players.status IN (0,1)")
        .pluck('players.id, players.name')
        .to_h
  end
end
결과: 약 00:04:58에 약 150.000줄이 업데이트되어 버전 1과 관련이 없습니다.

버전 3: 원본 SQL


마지막 방법은 원본 SQL을 사용하는 것입니다.Postgres UPDATE 문을 사용하여 단일 질의에서 통계 정보를 업데이트합니다.
class UpdatePlayerGameStatisticsV3
  def execute!
    ActiveRecord::Base.connection.execute(Arel.sql(update_sql))
  end

  private

  def update_sql
    <<-SQL
        UPDATE player_game_statistics AS stats
          SET player_name = players.name,
              updated_at = LOCALTIMESTAMP
        FROM (#{players_sql}) AS players
        WHERE stats.player_id = players.id
    SQL
  end

  def players_sql
    <<-SQL
        SELECT players.id, players.name
        FROM players
        LEFT OUTER JOIN teams ON teams.id = players.team_id
        WHERE teams.status = 1 AND players.status IN (0,1)
    SQL
  end
end
결과: 약 00:00:11에 약 15만 줄이 업데이트되었다.

세 가지 버전 모두에 대한 벤치마크 테스트:


다시 한 번 실시간성을 보면 v1이 v2보다 조금 좋은 것 같다. 비록 차이가 그리 크지 않을 수도 있지만.그러나 v3는 다른 버전의 약 28배 속도가 높기 때문에 여기서 뚜렷한 이긴 사람이다.
[#<Benchmark::Tms:0x00007fe48268deb8
  @cstime=0.0,
  @cutime=0.0,
  @label="v1",
  @real=309.4179189999704,
  @stime=15.808668,
  @total=241.84425000000002,
  @utime=226.035582>,
 #<Benchmark::Tms:0x00007fe47ee8eda0
  @cstime=0.0,
  @cutime=0.0,
  @label="v2",
  @real=341.4523780000163,
  @stime=14.207616000000002,
  @total=231.90784299999999,
  @utime=217.70022699999998>,
 #<Benchmark::Tms:0x00007fe48073ef08
  @cstime=0.0,
  @cutime=0.0,
  @label="v3",
  @real=12.004358999955002,
  @stime=0.001827999999996166,
  @total=0.003549999999968634,
  @utime=0.0017219999999724678>]

결론:


다른 개발자들이 어떻게 Rails 방식으로 이 문제들을 해결하는지 다른 활동 기록 솔루션을 탐색할 수 있었는데, 이것은 매우 재미있을 것이다.하지만 지금까지 Active Record는 복잡하고 데이터의 양이 많은 대량 업데이트/삽입에서 성능 병목을 보여 왔습니다.이것이 바로 SQL 파이가 쓸모가 있는 곳이다.
비록 기억 사전 (v2) 의 성능이 전체 활동 기록 방식 (v1) 보다 좋지는 않지만, 이것은 다른 상황에서 성능 향상에 도움이 되지 않는다는 것을 의미하지는 않는다.나는 자주 이런 상황을 만나는데, 그것은 확실히 매우 도움이 된다.
마지막으로 나는 서로 다른 문제/조회에 대해 이런 분석을 하는 것이 매우 중요하다는 것을 발견했다.모든 사례는 하나의 사례다.나의 경험에 따르면 SQL은 결국 대량의 데이터와/또는 복잡한 업데이트를 얻었다.
성능은 내가 가장 좋아하는 화제 중의 하나이다. 나는 여기, 나의 사이트와 다른 사이트에서 성능에 관한 글을 더 많이 쓸 것이다.나는 다른 사람과 성능 문제와 해결 방안을 토론하는 것을 매우 기쁘게 생각하므로 언제든지 방문하거나 연락 주십시오.

좋은 웹페이지 즐겨찾기