k6で秒間○回リクエストする　行ロックを取るような設計のボトルネック度をk6の負荷テストで検証してみた

## はじめに

思考実験として、投稿とそれに対するコメントやいいねができるシステムを考える（DBスキーマは以下のようなテーブル構成とする）。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/2c4e173e-305b-2acb-afd0-daeb30baa6f2.png)

このシステムでは、投稿テーブルでコメントの合計数を持つような設計になっているとする。そのような設計になっているがゆえに、コメントを行うAPIがあった場合には、以下のような実装になる必要がある。実装におけるポイントは、コメントを行うごとに投稿テーブルのcomment_countをインクリメントする必要があるので、投稿テーブルの行ロックを取る必要がある部分になる（ロックを取らずに投稿テーブルを更新すると、数値がおかしくなる）。

このような実装をしたとき、どれくらいのAPIコールがされると行ロックを取っていることがネックになり、サービスに影響が出る（ロック待ちでAPIの応答が遅くなる）が出るのか気になった。そこで今回は、どれくらいの負荷がかかるとこのような設計が破綻するのか？負荷テストを通じて検証してみようと思う。

負荷をかけるために利用するのは[k6](https://k6.io/docs/)というツール。

```js
// Node.js Expressの実装（SequlizeというORMを使って実装しているイメージ）
router.post('/:postId/comment', verifyAccess(), async (req, res) => {
	const { sequelize, models, CustomError } = req.app.locals;
	const { postId } = req.params;

	const transaction = await sequelize.transaction();
	try {
		const post = await models.post.findOne(
			{ where: { id: postId, enabled: 1 } },
			{ lock: true, transaction }
		);
		if (!post) throw new CustomError(404, 'data not Found');

		const { id } = await models.comment.create(
			{ ...req.body, postId, userId: req.tokens.userId },
			{ transaction }
		);

		const commentCount = await models.comment.count({ where: { postId }, transaction });
		post.commentCount = commentCount;
		await post.save({ transaction });

		const comment = await models.comment.findByPk(id, { transaction });

		await transaction.commit();

		res.status(200).json(comment.toJSON());
	} catch (error) {
		await transaction.rollback();
		res.status(500).error(error);
	}
});
```

## 何はともあれまずはk6を動かしてみる

インストール方法などは[Get started > Installation](https://k6.io/docs/get-started/installation/)を参照。私の環境はAlmaLinuxだったので、CentOSのインストール方法でインストールした。

```console
$ sudo dnf install https://dl.k6.io/rpm/repo.rpm
$ sudo dnf install k6
$ k6 --help

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

Usage:
  k6 [command]

Available Commands:
  ...
```

続いて公式の[Run local tests](https://k6.io/docs/get-started/running-k6/#running-local-tests)に書かれているスクリプトを実行して負荷テストを実行できるか？を確認してみる。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/ab044dcc-cfde-5c2b-72dc-4b7b6fb60c62.png)

画像のように、ひとまずk6で負荷テストを行うことはできた。結果として出力されているメトリクスの説明は[Built-in metrics](https://k6.io/docs/using-k6/metrics/reference/)に詳細が書かれている。

### k6の特徴について

k6ではJavaScriptでコードを書ける。そのためJavaScript（厳密には別物だがNode.js）で開発をしている開発者にとっては負荷テストを行う敷居が低くなる。それについては、公式の方でも触れられおり、開発チームでの負荷テストの敷居を下げられると書かれている。

> Grafana k6 is an open-source load testing tool that makes performance testing easy and productive for engineering teams. k6 is free, developer-centric, and extensible.
> （Grafana k6はオープンソースの負荷テストツールで、エンジニアリングチームのパフォーマンステストを簡単かつ生産的にします。）

## 実際に負荷をかけてみる

まずリクエストを行うためのスクリプトだが、以下のようなスクリプトになる。負荷をかけるAPIは、ログインを行うことで取得できるトークンをAuthorizationヘッダーのBearerに指定してAPIを呼び出せるようなAPIとしている。

```js
import http from 'k6/http';
import { sleep, check } from 'k6';

export function setup() {
	const signinUrl = 'http://localhost:3000/api/v1/internal/signin';
	const signinPayload = JSON.stringify({ email: 'sample@example.com', password: 'password' });
	const signinParams = { headers: { 'Content-Type': 'application/json' } };

	const signinRes = http.post(signinUrl, signinPayload, signinParams);
	const { token } = signinRes.json();

	const postUrl = 'http://localhost:3000/api/v1/post';
	const postPayload = JSON.stringify({ title: '負荷テスト' });
	const postParams = {
		headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
	};
	const postRes = http.post(postUrl, postPayload, postParams);
	const { id } = postRes.json();
	return { token, postId: id };
}

export default function (data) {
	const url = `http://localhost:3000/api/v1/post/${data.postId}/comment`;
	const payload = JSON.stringify({ content: 'コメント' });
	const params = {
		headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` }
	};
	const res = http.post(url, payload, params);
	check(res, {
		'is status 200': (r) => r.status === 200
	});
}
```

少し上記のコードについて補足をする。

- export function setup() {...}  
負荷テストを実行する前に実行される関数で、テストに必要な事前準備の処理を実装する。`return`により、後続の`default`の関数の引数で受け取ることのできるデータを返すこともできる。詳細は[Test lifecycle](https://k6.io/docs/using-k6/test-lifecycle/)を参照。

- export default function() {...}  
メインとなる処理を書く関数で、この関数に実装されている内容が負荷テストの内容になる。

上記のスクリプトでAPIに負荷をかけていくが、今回は1s以内の応答になる、つまり`http_req_duration`が1s以下に収まる最大の秒間リクエスト数を見ていきたいと思う。

秒間に一定のリクエスト（負荷）をかけるには、[constant-arrival-rate](https://k6.io/docs/using-k6/scenarios/executors/constant-arrival-rate/)というExecutorを利用するのが手っ取り早い。

というわけで、上記のコードに少し設定のためのコードを追加する。以下は秒間10リクエストを送るような設定。`rate`の部分を変えていくことで秒間のリクエストを増やせる。

```js
export const options = {
	scenarios: {
		constant_request_rate: {
			executor: 'constant-arrival-rate',
			rate: 10,
			timeUnit: '1s', // 毎秒10回の反復、すなわち10RPS
			duration: '30s',
			preAllocatedVUs: 100, // VUの初期プールの大きさ
			maxVUs: 300 // プレアロケートされたVUが足りない場合、さらに初期化できる上限数
		}
	}
};
```

以下で、実際に秒間に○○回リクエストをした結果をまとめてみた。

検証していたマシンスペックは以下（Windows上のVirtualboxで立てたAlmaLinux）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/09a28179-73fe-5f10-c816-7a6bc890a9ab.png)

また、ローカル環境で立てているDBはMySQLで、Dockerを利用してMySQLサーバーを起動していた。

- 参考：[How to generate a constant request rate in k6 with the new scenarios API?](https://k6.io/blog/how-to-generate-a-constant-request-rate-with-the-new-scenarios-api/)

### `rate: 10` の時

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/ccdb6531-2288-9e4a-ecaa-e06c539f2297.png)

つまりどうなの？というのはChatGPTやBardに聞けば教えてくれるが、要は以下のようなことがわかる。

- HTTPリクエストの平均応答時間: 25.81ms　←秒間10リクエストでは応答までに1s以内はOK
- HTTPリクエスト: 303回
- 1イテレーションあたりの平均時間: 26.59ms
- 1秒あたりのイテレーション数: 9.968001/s　←ほぼ秒間10リクエストということ

### `rate: 30` の時

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/d4f6fe4a-27db-1a99-816a-7d372dfbc0da.png)

- HTTPリクエストの平均応答時間: 29.56ms
- HTTPリクエスト: 903回
- 1イテレーションあたりの平均時間: 29.86ms
- 1秒あたりのイテレーション数: 29.831412/s

### `rate: 50` の時

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/dfd43596-58f5-a510-ac5f-79bd8847ccb5.png)

- HTTPリクエストの平均応答時間: 68.5ms
- HTTPリクエスト: 1503回
- 1イテレーションあたりの平均時間: 68.75ms
- 1秒あたりのイテレーション数: 49.700691/s

### `rate: 60` の時

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/44e65549-fda7-393a-9af7-e9bc860931de.png)

- HTTPリクエストの平均応答時間: 1.15秒
- HTTPリクエスト: 1779回
- 1イテレーションあたりの平均時間: 1.15秒
- 1秒あたりのイテレーション数: 55.660957/s　←秒間55リクエストしかできていない・・・

秒間60リクエストで1sを超えてしまった・・・。そしてBard曰く、

> この結果から、次のことが言えます。
>
> HTTPリクエストの平均応答時間が1.54秒であることは、一般的に許容できる範囲を超えています。システムの応答速度を向上させる必要があります。

とのことなので、今回の結果はNGのレベル…という結論になる。

## まとめとして

今回はk6を利用して行ロックによる非効率性を調べてみるということをやってみた。

ローカル環境での検証であり、マシンスペックなど全くもって本番環境のそれと違うので、今回の検証は本来的に意味はないが、それでも行ロックの設計がまずそうか？の判断をする上では参考になった気がする（まあk6を使ってみたかったというのも、モチベーションとしてあったのでやった意味はあったかなと思う）。