Aurora MySQL 5.6 と ElastiCache Redis 5.0.6 における並列カウントアップのパフォーマンス比較

MySQL では次のような SQL を書くことで対象レコードの値をインクリメントすることができます。 

INSERT INTO
  tbl (unique_key, count)
VALUES
  ('unique-key', 1)
ON DUPLICATE KEY UPDATE
  count = count + VALUES(count)
;

ただ、このような処理を同時に行うプロセスが何個もあるとめちゃくちゃ遅くなります。具体的にどれぐらい遅くなるのかや、Redis を使うと高速に処理できるのか気になったので調べてみました。

問題設定

次のように店舗・日単位の注文数を管理するテーブルがあり、注文がある度にインクリメントするとします。ただ、このテーブルはリアルタイムに更新する必要はなく、1 分程度のタイムラグは許容できるので、一旦 Redis の値をインクリメントし、後から MySQL に反映することもできるものとします。

CREATE TABLE IF NOT EXISTS `order_counts` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `shop_id` int(10) unsigned NOT NULL,
  `ordered_on` date NOT NULL,
  `count` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_shop_id_ordered_on` (`shop_id`,`ordered_on`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

例えば、shop_id = 1、2020-04-27 の注文数をインクリメントする場合、MySQL だと次のような SQL を発行することで直接更新することができます。

INSERT INTO
  order_counts (shop_id, ordered_on, count)
VALUES
  (1, '#{Date.today}', 1)
ON DUPLICATE KEY UPDATE
  count = count + VALUES(count)
;

ところが、リアルタイムに更新する場合、超人気店だと同じレコードに対してこの SQL が同時に何個も発行されるため、どれだけ並列に処理したとしても MySQL がボトルネックになり、並列数を増やすとロック競合が頻発してかえってスループットが落ちてしまいます。

この状況を作るために、次のように 4 スレッド(CLIENT_COUNT = 4)で 10 回ずつ少しだけスリープを入れつつ注文数をインクリメントするスクリプトを実行する ECS タスクを起動します。

prng = Random.new(42)
total_time = 0
ths = Array.new(CLIENT_COUNT) do
  sleep_times = REPEAT_COUNT.times.map { prng.rand }

  Thread.new do
    c = new_client

    REPEAT_COUNT.times do |i|
      s = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      countup
      total_time += Process.clock_gettime(Process::CLOCK_MONOTONIC) - s
      sleep sleep_times[i]
    end
  end
end
ths.each(&:join)

タスクの数は 1 個、10 個、50 個、100 個と増やしていくことにします。

Aurora MySQL のベンチマーク結果

Aurora MySQL は次の構成にしました。

  • Instance type: db.r5.large
  • Engine version: 5.6.mysql_aurora.1.22.1

使ったコードは mysql-bench にアップしてあります。
INSERT INTO ... ON DUPLICATE KEY UPDATE を使う方法と UPDATE を使う方法を試しました。

INSERT INTO … ON DUPLICATE KEY UPDATE を使う方法

mysql-benchrun_task.sh に次の環境変数を指定して実行しました。

  • TASK_COUNT=N
    • N=1, 10, 50, 100
  • CLUSTER=test
  • STARTED_AT="$(gdate -u -d '2-minutes' '+%F %T')"
  • UPDATE_METHOD=insert_and_update
TASK_COUNT 1 SQL の平均時間 (sec) RW-shared RW-excl
1 0.0423 spins 0, rounds 0, OS waits 0 spins 0, rounds 0, OS waits 0
10 0.0256 spins 1, rounds 2, OS waits 0 spins 30, rounds 250, OS waits 3
50 0.0991 spins 55, rounds 893, OS waits 27 spins 402, rounds 3726, OS waits 59
100 0.2690 spins 158, rounds 3530, OS waits 100 spins 765, rounds 7855, OS waits 115

タスクが増えることで、たった 1 レコード更新するのに 200 ms 以上かかっています。また、InsertLatency が増えていることがわかります。

UPDATE を使う方法

mysql-benchrun_task.sh に次の環境変数を指定して実行しました。

  • TASK_COUNT=N
    • N=1, 10, 50, 100
  • CLUSTER=test
  • STARTED_AT="$(gdate -u -d '2-minutes' '+%F %T')"
  • UPDATE_METHOD=select_and_update
TASK_COUNT 1 SQL の平均時間 (sec) RW-shared RW-excl
1 0.0399 spins 0, rounds 0, OS waits 0 spins 0, rounds 0, OS waits 0
10 0.0307 spins 0, rounds 0, OS waits 0 spins 3, rounds 2, OS waits 0
50 0.0743 spins 31, rounds 630, OS waits 19 spins 61, rounds 733, OS waits 18
100 0.2712 spins 211, rounds 1643, OS waits 35 spins 158, rounds 1392, OS waits 35

タスクが増えることで、たった 1 レコード更新するのに 200 ms 以上かかっています。また、UpdateLatency が増えていることがわかります。

UPDATE の方が INSERT INTO ... ON DUPLICATE KEY UPDATE よりは速いと思っていたんですが、latency を見る限り UPDATE の方が遅いのが意外でした

Redis のベンチマーク結果

ElastiCache Redis は次の構成にしました。

  • Node type: cache.m5.large
  • Engine Version Compatibility: 5.0.6

使ったコードは redis-bench にアップしてあります。
run_task.sh に次の環境変数を指定して実行しました。

  • TASK_COUNT=N
    • N=1, 10, 50, 100
  • CLUSTER=test
  • STARTED_AT="$(gdate -u -d '2-minutes' '+%F %T')"
TASK_COUNT 1 INCRBY の平均時間 (sec)
1 0.0111
10 0.0121
50 0.0129
100 0.0135

Redis の場合はタスクが増えてもほとんど影響を受けてないですね。EngineCPUUtilization もかなり余裕があります。

なお、最終的には Redis の内容を MySQL に反映しないといけないわけですが、次のように GETSET を使って値を取得しつつリセットすることになると思います。

dates = [Date.today, (Time.now - 300).to_date].uniq
shop_ids.each do |shop_id|
  dates.each do |date|
    count = redis.getset("shop_id:#{shop_id}:#{date}", 0).to_i
    next if count.zero?

    # Aurora MySQL の更新処理
  end
end

まとめ

以上の内容をまとめると、次のようなことが言えます

  • Aurora MySQL の INSERT INTO ... ON DUPLICATE KEY UPDATE でレコードの値をインクリメントするのは、同時に SQL を発行するプロセスが増えると極端に遅くなる
  • Aurora MySQL の UPDATE でインクリメントするのも INSERT INTO ... ON DUPLICATE KEY UPDATE でインクリメントするのも速度面ではほとんど変わらない
  • Redis は特定のアイテムを同時にインクリメントするプロセスが増えてもほとんど影響を受けない
    • ※ 今回試した並列程度であれば数の話