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-bench の run_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-bench の run_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 は特定のアイテムを同時にインクリメントするプロセスが増えてもほとんど影響を受けない
- ※ 今回試した並列程度であれば数の話