Y

ISUCON12予選の復習をしました 10

この記事は何か?

ISUCON12予選の復習記録です。

第10回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「10 AddTenant APISQLite DBを作るのをやめる」を試します。

目次

  1. 「10 AddTenant APISQLite DBを作るのをやめる」を試す

1.「10 AddTenant APISQLite DBを作るのをやめる」を試す

3台構成準備のその2です。

1台構成であればAdminのAddTenant APISQLiteのDBを追加する、でよいのですが、上の方にも書いた通り3台構成にするとAdminのAddTenantをするサーバとテナントの処理サーバが一致していないとこの処理がうまくいきません。テナントのサブドメインはベンチマーカーから指定されるランダム文字列なので先頭文字を使って既存テナントと新規テナントを振り分けるみたいなこともできないためnginxが /api/admin/tenants/add を受け取ったときに適切なサーバに振り分けるのはなかなか難しい…ということで新規DB作成処理のタイミングを変えます。

まず、AddTenant APIでcreateTenantDB関数を呼び出すのをやめます。そして、テナントAPIに出てくるconnectToTenantDB関数の中でテナントのDBファイルが存在するかチェックして、なければcreateTenantDBを呼ぶという処理に変えると、nginxでテナントの振り分けさえ出来ていれば全てのSQLiteの処理は振り分けたサーバで行われるようになるので、3台構成が可能になります。

上記のとおりです。

私のISUCON歴では、3台全てを利用できることがなかったです。全台を利用するのは必須だと思うのでやっていきます。

isucon@ip-192-168-0-12:~/webapp/go$ git diff
--- a/go/isuports.go
+++ b/go/isuports.go
@@ -85,6 +85,17 @@ func tenantDBPath(id int64) string {
 // テナントDBに接続する
 func connectToTenantDB(id int64) (*sqlx.DB, error) {
        p := tenantDBPath(id)
+
+       // テナントDBが存在しない場合は新規に作成する
+       if _, err := os.Stat(p); err == nil {
+       } else if errors.Is(err, os.ErrNotExist) {
+               if err := createTenantDB(id); err != nil {
+                       return nil, fmt.Errorf("error createTenantDB: id=%d %w", id, err)
+               }
+       } else {
+               return nil, fmt.Errorf("error file exist check")
+       }
+
        db, err := sqlx.Open(sqliteDriverName, fmt.Sprintf("file:%s?mode=rw", p))
        if err != nil {
                return nil, fmt.Errorf("failed to open tenant DB: %w", err)
@@ -530,9 +541,9 @@ func tenantsAddHandler(c echo.Context) error {
        // NOTE: 先にadminDBに書き込まれることでこのAPIの処理中に
        //       /api/admin/tenants/billingにアクセスされるとエラーになりそう
        //       ロックなどで対処したほうが良さそう
-       if err := createTenantDB(id); err != nil {
-               return fmt.Errorf("error createTenantDB: id=%d name=%s %w", id, name, err)
-       }
+       //if err := createTenantDB(id); err != nil {
+        //     return fmt.Errorf("error createTenantDB: id=%d name=%s %w", id, name, err)
+       //}

        res := TenantsAddHandlerResult{
                Tenant: TenantWithBilling{
  • 変更前のスコア
22:42:51.168658 SCORE: 15122 (+16087 -965(6%))
  • 変更後のスコア
10:29:17.392487 SCORE: 9872 (+11893 -2021(17%))

感想

エラーが多くなりました。何かが足りないと思いますが、とりあえず気にせず前に進みます。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

2 ISUCON12 予選当日マニュアル

ISUCON12予選の復習をしました 9

この記事は何か?

ISUCON12予選の復習記録です。

第9回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「9 Ranking APIでランキング集計するのをやめる」を試します。

先に結論を書くと、この対応は「失敗」しました。途中までの経緯を書きます。

目次

  1. リクエスト傾向確認
  2. アップロードされるCSVファイル確認
  3. rankingテーブルを利用するように変更

1. リクエスト傾向確認

alpでみていくと、ranking APIの呼び出される回数とscoreが入稿される回数は10~20倍くらい差があります。

ということで、確認しました。その結果、解説に書かれているとおり、/rankingは/scoreの30倍くらい差があります。

  • ALP抜粋
+-------+--------+--------+----------+--------+--------+--------+--------+--------+-----------+------------+--------------+-----------+--------+---------------------------------------+
| COUNT |  MIN   |  MAX   |   SUM    |  AVG   |   P1   |  P50   |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) | METHOD |                  URI                  |
+-------+--------+--------+----------+--------+--------+--------+--------+--------+-----------+------------+--------------+-----------+--------+---------------------------------------+
|   106 |  0.012 |  3.656 |   31.532 |  0.297 |  0.000 |  0.144 |  2.560 |  0.536 |    39.000 |     62.000 |     5656.000 |    53.358 | POST   | /api/organizer/competition/.*/score   |
|  3320 |  0.012 |  9.100 | 2489.008 |  0.750 |  0.012 |  0.472 |  4.272 |  0.887 |     0.000 |  17281.000 | 47525692.000 | 14314.967 | GET    | /api/player/competition/.*/ranking    |
+-------+--------+--------+----------+--------+--------+--------+--------+--------+-----------+------------+--------------+-----------+--------+---------------------------------------+
  • ALP全体
isucon@ip-192-168-0-12:~/webapp$ alp -f /var/log/nginx/access.log --cnt --aggregates='/api/player/competition/.*/ranking','/api/organizer/competition/.*/score','/api/player/player/.*','/api/organizer/competition/.*/finish','/api/organizer/player/.*/disqualified'
+-------+--------+--------+----------+--------+--------+--------+--------+--------+-----------+------------+--------------+-----------+--------+---------------------------------------+
| COUNT |  MIN   |  MAX   |   SUM    |  AVG   |   P1   |  P50   |  P99   | STDDEV | MIN(BODY) | MAX(BODY)  |  SUM(BODY)   | AVG(BODY) | METHOD |                  URI                  |
+-------+--------+--------+----------+--------+--------+--------+--------+--------+-----------+------------+--------------+-----------+--------+---------------------------------------+
|     1 | 15.724 | 15.724 |   15.724 | 15.724 | 15.724 | 15.724 | 15.724 |  0.000 |    55.000 |     55.000 |       55.000 |    55.000 | POST   | /initialize                           |
|     1 |  0.000 |  0.000 |    0.000 |  0.000 |  0.000 |  0.000 |  0.000 |  0.000 |   205.000 |    205.000 |      205.000 |   205.000 | GET    | /api/organizer/competitions           |
|     1 |  0.000 |  0.000 |    0.000 |  0.000 |  0.000 |  0.000 |  0.000 |  0.000 |   479.000 |    479.000 |      479.000 |   479.000 | GET    | /index.html                           |
|     1 |  0.000 |  0.000 |    0.000 |  0.000 |  0.000 |  0.000 |  0.000 |  0.000 | 33294.000 |  33294.000 |    33294.000 | 33294.000 | GET    | /js/app.3a4ec98c.js                   |
|     1 |  0.000 |  0.000 |    0.000 |  0.000 |  0.000 |  0.000 |  0.000 |  0.000 |  4868.000 |   4868.000 |     4868.000 |  4868.000 | GET    | /css/app.83b4c321.css                 |
|     1 |  0.000 |  0.000 |    0.000 |  0.000 |  0.000 |  0.000 |  0.000 |  0.000 |   162.000 |    162.000 |      162.000 |   162.000 | GET    | /favicon.ico                          |
|    11 |  0.004 |  0.520 |    1.088 |  0.099 |  0.004 |  0.016 |  0.228 |  0.153 |    39.000 |    185.000 |     1664.000 |   151.273 | POST   | /api/organizer/player/.*/disqualified |
|    15 |  0.928 | 11.904 |   53.232 |  3.549 |  0.928 |  2.184 | 10.264 |  3.159 | 14950.000 |  33660.000 |   393099.000 | 26206.600 | POST   | /api/organizer/players/add            |
|    16 |  0.004 |  0.168 |    0.896 |  0.056 |  0.000 |  0.052 |  0.136 |  0.047 |    39.000 |    200.000 |     2263.000 |   141.438 | POST   | /api/admin/tenants/add                |
|    70 |  0.004 | 30.000 |  106.576 |  1.523 |  0.004 |  0.408 |  9.060 |  3.805 |     0.000 |   5203.000 |   119388.000 |  1705.543 | GET    | /api/organizer/billing                |
|    80 |  0.004 | 23.352 |   88.356 |  1.104 |  0.000 |  0.112 | 15.248 |  3.529 |     0.000 |   1614.000 |   110481.000 |  1381.013 | GET    | /api/admin/tenants/billing            |
|    84 |  0.004 |  1.496 |   12.664 |  0.151 |  0.000 |  0.072 |  1.248 |  0.245 |     0.000 | 602067.000 |  3698133.000 | 44025.393 | GET    | /api/organizer/players                |
|    85 |  0.004 |  3.244 |   39.848 |  0.469 |  0.004 |  0.228 |  2.576 |  0.608 |     0.000 |     39.000 |     1761.000 |    20.718 | POST   | /api/organizer/competition/.*/finish  |
|    92 |  0.008 |  1.100 |   13.672 |  0.149 |  0.008 |  0.072 |  1.092 |  0.199 |    39.000 |    223.000 |    17613.000 |   191.446 | POST   | /api/organizer/competitions/add       |
|   106 |  0.012 |  3.656 |   31.532 |  0.297 |  0.000 |  0.144 |  2.560 |  0.536 |    39.000 |     62.000 |     5656.000 |    53.358 | POST   | /api/organizer/competition/.*/score   |
|   502 |  0.004 |  8.236 |   96.832 |  0.193 |  0.000 |  0.108 |  1.248 |  0.461 |     0.000 |   2831.000 |   519832.000 |  1035.522 | GET    | /api/player/competitions              |
|  3320 |  0.012 |  9.100 | 2489.008 |  0.750 |  0.012 |  0.472 |  4.272 |  0.887 |     0.000 |  17281.000 | 47525692.000 | 14314.967 | GET    | /api/player/competition/.*/ranking    |
|  6936 |  0.004 | 30.004 | 2716.212 |  0.392 |  0.004 |  0.196 |  2.644 |  1.042 |     0.000 |   1457.000 |  6777835.000 |   977.197 | GET    | /api/player/player/.*                 |
+-------+--------+--------+----------+--------+--------+--------+--------+--------+-----------+------------+--------------+-----------+--------+---------------------------------------+

2. アップロードされるCSVファイル確認

ベンチマークは同じ大会にscoreを何度か入稿してきますが、scoreの件数は減らない(追記式のスコアデータがCSVとして何度も入稿される)というベンチマーカーの仕様があってそれを知った上であれば ON DUPLICATE KEY UPDATE を使って1クエリでいけるのでMySQLを使いました。

ということで、確認しました。その結果、解説に書かれているとおり、socreの件数は変更がありませんでした。

  • CSVを出力するコードを一時的に追加
isucon@ip-192-168-0-12:~/webapp/go$ git diff
diff --git a/go/isuports.go b/go/isuports.go
index c943073..3e16807 100644
--- a/go/isuports.go
+++ b/go/isuports.go
@@ -1103,6 +1103,22 @@ func competitionScoreHandler(c echo.Context) error {
        }
        defer f.Close()

+         // アップロードされるファイルの傾向を調べるために一時的にファイルに保存
+  currentTime := time.Now()
+  outFilePath := fmt.Sprintf("/tmp/%v-%v-%v.csv", v.tenantID, competitionID, currentTime.UnixNano())
+  outFile, err := os.Create(outFilePath)
+  if err != nil {
+    return err
+  }
+  io.Copy(outFile, f)
+  defer outFile.Close()
+
+  f, err = fh.Open()
+  if err != nil {
+    return fmt.Errorf("error fh.Open FormFile(scores): %w", err)
+  }
+  defer f.Close()
+
        r := csv.NewReader(f)
        headers, err := r.Read()
        if err != nil {
  • 出力されたCSV一覧
ubuntu@ip-192-168-0-12:/tmp$ ls | sort
1-4068d268-26b4-11ed-a74f-0ef728f3fa93-1661679113511136695.csv
1-5dc158aa-26b4-11ed-a74f-0ef728f3fa93-1661679164143958364.csv
16-3fbc64fd-26b4-11ed-a74f-0ef728f3fa93-1661679112403862815.csv
16-4375511b-26b4-11ed-a74f-0ef728f3fa93-1661679118652853644.csv
...
99-45611dc0-26b4-11ed-a74f-0ef728f3fa93-1661679121847557828.csv
99-45611dc0-26b4-11ed-a74f-0ef728f3fa93-1661679122419324934.csv
  • 出力されたCSV内の行数
ubuntu@ip-192-168-0-12:/tmp$ cat 99-45611dc0-26b4-11ed-a74f-0ef728f3fa93-1661679121847557828.csv | wc
     76      77    1155
ubuntu@ip-192-168-0-12:/tmp$ cat 99-45611dc0-26b4-11ed-a74f-0ef728f3fa93-1661679122419324934.csv | wc
     76      77    1080
  • 出力されたCSV内の内容

3. rankingテーブルを利用するように変更

以下手順で変更しました。しかし、ベンチマークが失敗します。ランキングが正しくないとエラーが発生します。

  1. rankingテーブル追加
  2. /score時にranking追加
  3. /ranking時にrankingテーブルを参照するように変更

感想

この対応方法については別の方のブログを参照するなどしてリベンジしたいと思います。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

2 ISUCON12 予選当日マニュアル

ISUCON12予選の復習をしました 8

この記事は何か?

ISUCON12予選の復習記録です。

第8回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「8 tenantDB player_scoreにINDEXをはる」を試します。

目次

1.「8 tenantDB player_scoreにINDEXをはる」を試す

ISUCON12予選は2つのデータソースがあります。AdminDBとTenantDBです。TenantDBのテーブル構造は以下です。

/home/isucon/webapp/sql/tenant/vi 10_schema.sql

DROP TABLE IF EXISTS competition;
DROP TABLE IF EXISTS player;
DROP TABLE IF EXISTS player_score;

CREATE TABLE competition (
  id VARCHAR(255) NOT NULL PRIMARY KEY,
  tenant_id BIGINT NOT NULL,
  title TEXT NOT NULL,
  finished_at BIGINT NULL,
  created_at BIGINT NOT NULL,
  updated_at BIGINT NOT NULL
);

CREATE TABLE player (
  id VARCHAR(255) NOT NULL PRIMARY KEY,
  tenant_id BIGINT NOT NULL,
  display_name TEXT NOT NULL,
  is_disqualified BOOLEAN NOT NULL,
  created_at BIGINT NOT NULL,
  updated_at BIGINT NOT NULL
);

CREATE TABLE player_score (
  id VARCHAR(255) NOT NULL PRIMARY KEY,
  tenant_id BIGINT NOT NULL,
  player_id VARCHAR(255) NOT NULL,
  competition_id VARCHAR(255) NOT NULL,
  score BIGINT NOT NULL,
  row_num BIGINT NOT NULL,
  created_at BIGINT NOT NULL,
  updated_at BIGINT NOT NULL
);

上記のとおりインデックスがありません。最も大きなテナントの件数を確認します。

/home/isucon/webapp/tenant_db/1.db

sqlite> select count(*) from competition;
22
sqlite> select count(*) from player;
5001
sqlite> select count(*) from player_score;
1675597

player_scoreにインデックスをはると効果がありそうです。TenantDBに対するSELECT文を全部見てみます。

// player_score, tenant_id, competition_id, player_id
if err := tenantDB.SelectContext(
ctx,
&scoredPlayerIDs,
"SELECT DISTINCT(player_id) FROM player_score WHERE tenant_id = ? AND competition_id = ?",

// competition, PK
if err := tenantDB.SelectContext(
ctx,
&cs,
"SELECT * FROM competition WHERE tenant_id=?",

// player, tenant_id, created_at
if err := tenantDB.SelectContext(
ctx,
&pls,
"SELECT * FROM player WHERE tenant_id=? ORDER BY created_at DESC",

// player, tenant_id
if err := tenantDB.SelectContext(
ctx,
&playerIDs,
"SELECT DISTINCT(id) FROM player WHERE tenant_id = ?",

// competition, tenant_id, created_at
if err := tenantDB.SelectContext(
ctx,
&cs,
"SELECT * FROM competition WHERE tenant_id=? ORDER BY created_at DESC",

// competition, tenant_id, created_at
if err := tenantDB.SelectContext(
ctx,
&cs,
"SELECT * FROM competition WHERE tenant_id = ? ORDER BY created_at ASC",

// ..? (JOINの時の適切なインデックスとは?)
if err := tenantDB.SelectContext(
ctx,
&pss,
"SELECT player_score.*, player.display_name FROM player_score JOIN player ON player.id = player.score_id WHERE player_score.tenant_id = ? AND competition_id = ? ORDER BY row_num DESC",

// competition, tenant_id, created_at
if err := tenantDB.SelectContext(
ctx,
&cs,
"SELECT * FROM competition WHERE tenant_id=? ORDER BY created_at DESC",

// competition, created_at
if err := tenantDB.SelectContext(
ctx,
&cs,
"SELECT * FROM competition ORDER BY created_at DESC",

上記のとおり、解説にあるとおり以下のインデックスが有効そうです。ただし「player_id」を含める発想はありませんでした。

CREATE INDEX idx_score ON player_score (tenant_id, competition_id, player_id);

また、初期データに対してもインデックスを作成します。

cd /home/isucon/webapp/tenant_db
for db in *.db; do echo "CREATE INDEX idx_score ON player_score (tenant_id, competition_id, player_id);" | sqlite3 $db; done
$ git diff
@@ -30,3 +30,5 @@ CREATE TABLE player_score (
   created_at BIGINT NOT NULL,
   updated_at BIGINT NOT NULL
 );
+
+CREATE INDEX idx_score ON player_score (tenant_id, competition_id, player_id);
09:21:44.540379 SCORE: 9868 (+11747 -1879(16%))
22:42:51.168658 SCORE: 15122 (+16087 -965(6%))

解説にあるとおりスコアが1.5倍になりました。

感想

必要なインデックスの洗い出しや、for db in *.db.. のコマンドを時間かけずにできるようにする必要がありそうです。

予選突破スコアは22,000点あたりなので、近づいてきました。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

2 ISUCON12 予選当日マニュアル

ISUCON12予選の復習をしました 7

この記事は何か?

ISUCON12予選の復習記録です。

第7回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「7 Finish APIでBillingReportを生成する」を試します。

目次

1.「7 Finish APIでBillingReportを生成する」を試す

1.「7 Finish APIでBillingReportを生成する」を試す

今回の対応は難しいです。これができた人は尊敬します。

反映までの猶予時間について
一部APIは他のリクエストへの反映までに許容される猶予時間があります

POST /api/organizer/competition/:competition_id/finish
以下のエンドポイントは上記APIの情報の反映まで、レスポンスを返してから3秒の猶予が許容されます

GET /api/organizer/billing
GET /api/admin/tenants/billing

上記は「ISUCON12 予選当日マニュアル」に記載されています。ヒントですがこれが何を意味しているか分かりませんでした。過去問でも同じような「許容される猶予時間」についての記載があるので、慣れる必要があります。(ISUCON文学に慣れる必要がある)

この意味は「/finishエンドポイントの完了後、3秒以内には、結果反映を検証するベンチ処理は発生しない」という意味だと今は理解しています。正解は今後ベンチマークデバッグする時に答え合わせします。

エンドポイント「テナントごとの課金レポート /api/admin/tenants/billing」は、テナント毎の課金レポートを出力しますが、全テナントの全大会のデータを集計する必要があり、大変重い処理です。「この集計処理を/billingではなく、/finishエンドポイントで事前に実行しておく」というのが今回の対応になります。

この発想に気づくことも僕には難しいですが、実装するのも注意が必要です。以下プロセスで行います。

1. テーブル作成
2. /initializeエンドポイントで初期データ分の集計処理を追加
3. /finishエンドポイントにデータ登録処理を追加
4. /billingエンドポイントにデータ参照処理を追加
5. /initializeエンドポイントで実行されるデータ初期化処理に追加 (TRUNCATE追加)
  • テーブル作成
CREATE TABLE `billing_report` (
  `tenant_id` BIGINT UNSIGNED NOT NULL,
  `competition_id` VARCHAR(255) NOT NULL,
  `competition_title` VARCHAR(255) NOT NULL,
  `player_count` BIGINT NOT NULL,
  `visitor_count` BIGINT NOT NULL,
  `billing_player_yen` BIGINT NOT NULL,
  `billing_visitor_yen` BIGINT NOT NULL,
  `billing_yen` BIGINT NOT NULL,
  PRIMARY KEY(`tenant_id`, `competition_id`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;
diff --git a/go/isuports.go b/go/isuports.go
index dc182fb..c943073 100644
--- a/go/isuports.go
+++ b/go/isuports.go
@@ -449,6 +449,17 @@ type PlayerScoreJoinPlayerRow struct {
        DisplayName   string `db:"display_name"`
 }

+type BillingReportRow struct {
+       TenantID          int64  `db:"tenant_id"`
+       CompetitionID     string `db:"competition_id"`
+       CompetitionTitle  string `db:"competition_title"`
+       PlayerCount       int64  `db:"player_count"`
+       VisitorCount      int64  `db:"visitor_count"`
+       BillingPlayerYen  int64  `db:"billing_player_yen"`
+       BillingVisitorYen int64  `db:"billing_visitor_yen"`
+       BillingYen        int64  `db:"billing_yen"`
+}
+
 // 排他ロックのためのファイル名を生成する
 func lockFilePath(id int64) string {
        tenantDBDir := getEnv("ISUCON_TENANT_DB_DIR", "../tenant_db")
@@ -717,11 +728,32 @@ func tenantsBillingHandler(c echo.Context) error {
                                return fmt.Errorf("failed to Select competition: %w", err)
                        }
                        for _, comp := range cs {
+                               /*
                                report, err := billingReportByCompetition(ctx, tenantDB, t.ID, comp.ID)
                                if err != nil {
                                        return fmt.Errorf("failed to billingReportByCompetition: %w", err)
                                }
                                tb.BillingYen += report.BillingYen
+                               */
+
+                               // 請求情報テーブルから取得
+                               br := BillingReportRow{}
+                               if err := adminDB.GetContext(
+                                       ctx,
+                                       &br,
+                                       // 最後にCSVに登場したスコアを採用する = row_numが一番大きいもの
+                                       "SELECT * FROM billing_report WHERE tenant_id = ? AND competition_id = ?",
+                                       t.ID,
+                                       comp.ID,
+                               ); err != nil {
+                                       // 行がない = スコアが記録されてない
+                                       if errors.Is(err, sql.ErrNoRows) {
+                                               continue
+                                       }
+                                       return fmt.Errorf("failed to billingReportByCompetition: %w", err)
+                               }
+
+                               tb.BillingYen += br.BillingYen
                        }
                        tenantBillings = append(tenantBillings, tb)
                        return nil
@@ -1008,6 +1040,13 @@ func competitionFinishHandler(c echo.Context) error {
                        now, now, id, err,
                )
        }
+
+       // 請求情報保存 TODO 非同期処理
+       err = insertBillingReport(ctx, tenantDB, v.tenantID, id)
+       if err != nil {
+               return fmt.Errorf("error insertBillingReport: %w", err)
+       }
+
        return c.JSON(http.StatusOK, SuccessResult{Status: true})
 }

@@ -1690,5 +1729,59 @@ func initializeHandler(c echo.Context) error {
        res := InitializeHandlerResult{
                Lang: "go",
        }
+
+       // 請求情報保存
+       ctx := context.Background()
+
+       ts := []TenantRow{}
+       if err := adminDB.SelectContext(ctx, &ts, "SELECT * FROM tenant ORDER BY id DESC"); err != nil {
+               return fmt.Errorf("error Select tenant: %w", err)
+       }
+
+       for _, t := range ts {
+               tenantDB, err := connectToTenantDB(t.ID)
+               if err != nil {
+                       return fmt.Errorf("failed to connectToTenantDB: %w", err)
+               }
+               defer tenantDB.Close()
+               cs := []CompetitionRow{}
+               if err := tenantDB.SelectContext(
+                       ctx,
+                       &cs,
+                       "SELECT * FROM competition ORDER BY created_at DESC",
+               ); err != nil {
+                       return fmt.Errorf("error Select competition: %w", err)
+               }
+
+               for _, comp := range cs {
+                       err := insertBillingReport(ctx, tenantDB, comp.TenantID, comp.ID)
+                       if err != nil {
+                               return fmt.Errorf("error insertBillingReport: %w", err)
+                       }
+               }
+       }
+
        return c.JSON(http.StatusOK, SuccessResult{Status: true, Data: res})
 }
+
+// 請求情報保存
+func insertBillingReport(ctx context.Context, tenantDB dbOrTx, tenantID int64, competitonID string) error {
+       report, err := billingReportByCompetition(ctx, tenantDB, tenantID, competitonID)
+       if err != nil {
+               return fmt.Errorf("failed to billingReportByCompetition: %w", err)
+       }
+
+       if _, err := adminDB.ExecContext(
+               ctx,
+               "INSERT INTO billing_report (tenant_id, competition_id, competition_title, player_count, visitor_count, billing_player_yen, billing_visitor_yen, billing_yen) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+               tenantID, report.CompetitionID, report.CompetitionTitle, report.PlayerCount, report.VisitorCount, report.BillingPlayerYen, report.BillingVisitorYen, report.BillingYen,
+       ); err != nil {
+               return fmt.Errorf(
+                       "error Insert billing_report: %w",
+                       err,
+               )
+       }
+
+       return nil
+}
+
diff --git a/sql/init.sql b/sql/init.sql
index 990be9a..b4ff9e2 100644
--- a/sql/init.sql
+++ b/sql/init.sql
@@ -2,3 +2,4 @@ DELETE FROM tenant WHERE id > 100;
 DELETE FROM visit_history WHERE created_at >= '1654041600';
 UPDATE id_generator SET id=2678400000 WHERE stub='a';
 ALTER TABLE id_generator AUTO_INCREMENT=2678400000;
+TRUNCATE table billing_report;
01:57:29.017960 SCORE: 5617 (+5617 0(0%))
09:21:44.540379 SCORE: 9868 (+11747 -1879(16%))

感想

スコアが大幅に増えましたが、失敗によるマイナスが発生しています。上記のコード修正では足りない何かがあるからだと思います。詳細は今後別の人の解説を見て解決します。

また、「請求情報保存 TODO 非同期処理」の部分は解説にあるとおり今後goroutineを利用してみます。

予選当日マニュアルに書かれている「許容される猶予時間」の対応方法については慣れておきたいです。また、今回テーブルを増やしましたが、テーブル増やすことも毎回あるのでそういう発想を忘れないようにしたいです。

今回SQLiteへの検索の一部をMySQLに移しましたが、これは3台構成に変更するための布石となっています。このあたりは次回以降記載します。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

2 ISUCON12 予選当日マニュアル

ISUCON12予選の復習をしました 6

この記事は何か?

ISUCON12予選の復習記録です。

第6回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「6 adminDB visit_historyの初期データをコンパクトにする」を試します。

目次

  1. 「6 adminDB visit_historyの初期データをコンパクトにする」を試す

MySQLに入っているvisit_historyデータは100テナントで322万行の巨大データですが、「アクセスをしたかどうか」だけがわかれば大丈夫というアプリケーションの作りになっているため、全件を保持しておく必要はありません。

ISUCON12予選のデータソースは2つあります。AdminDBとTenantDBです。AdminDBは以下のとおり3つのテーブルで構成されており、visit_historyテーブルに300万件のデータがあるためこのテーブルを対策する必要があることが分かります。

  • AdminDB初期データ件数
mysql> select count(*) from id_generator;
+----------+
| count(*) |
+----------+
|        1 |
+----------+
1 row in set (0.00 sec)

mysql> select count(*) from tenant;
+----------+
| count(*) |
+----------+
|      106 |
+----------+
1 row in set (0.00 sec)

mysql> select count(*) from visit_history;
+----------+
| count(*) |
+----------+
|  3225470 |
+----------+
1 row in set (1.33 sec)

以下のとおり、visit_historyへの参照は1つの関数から呼び出されています。SQLを確認すると「MIN(created_at)」とあり、プレイヤーのテナントID&大会IDに対して最も古い参照履歴レコードだけを利用していることがわかります。

■ 参照

□ 参照エンドポイント

// テナント管理者向けAPI
// GET /api/organizer/billing
// テナント内の課金レポートを取得する
func billingHandler(c echo.Context) error {

// SaaS管理者用API
// テナントごとの課金レポートを最大10件、テナントのid降順で取得する
// GET /api/admin/tenants/billing
// URL引数beforeを指定した場合、指定した値よりもidが小さいテナントの課金レポートを取得する
func tenantsBillingHandler(c echo.Context) error {

□ 参照エンドポイントから呼ばれるSQL

// 大会ごとの課金レポートを計算する
func billingReportByCompetition(ctx context.Context, tenantDB dbOrTx, tenantID int64, competitonID string) (*BillingReport, error) {

"SELECT player_id, MIN(created_at) AS min_created_at FROM visit_history WHERE tenant_id = ? AND competition_id = ? GROUP BY player_id",

■ 更新

□ 更新エンドポイント

// 参加者向けAPI
// GET /api/player/competition/:competition_id/ranking
// 大会ごとのランキングを取得する
func competitionRankingHandler(c echo.Context) error {

□ 更新エンドポイントから呼ばれるSQL

"INSERT INTO visit_history (player_id, tenant_id, competition_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
  • 不要なデータを削除する

解説のとおり不要データを削除します。結果、300万件から20万件に削減できました。

mysql> CREATE TABLE `visit_history_tmp` (
      ->   `player_id` VARCHAR(255) NOT NULL,
      ->   `tenant_id` BIGINT UNSIGNED NOT NULL,
      ->   `competition_id` VARCHAR(255) NOT NULL,
      ->   `created_at` BIGINT NOT NULL,
      ->   `updated_at` BIGINT NOT NULL,
      ->   INDEX `tenant_id_idx` (`tenant_id`)
      -> ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;
  Query OK, 0 rows affected (0.04 sec)

mysql> insert into visit_history_tmp select * from visit_history;
Query OK, 3226340 rows affected (24.40 sec)
Records: 3226340  Duplicates: 0  Warnings: 0

mysql> insert into visit_history select player_id, tenant_id, competition_id, min(created_at) as created_at, min(updated_at) as updated_at from visit_history_tmp group by player_id, tenant_id, competition_id;
Query OK, 201131 rows affected (27.66 sec)
Records: 201131  Duplicates: 0  Warnings: 0

mysql> select count(*) from visit_history;
+----------+
| count(*) |
+----------+
|   201131 |
+----------+
1 row in set (0.02 sec)
  • 変更前のスコア
22:36:19.017277 SCORE: 6390 (+6390 0(0%))
  • 変更後のスコア
01:57:29.017960 SCORE: 5617 (+5617 0(0%))
  • 変更後のtopコマンド結果

DBのCPU負荷が少なくなっていることが分かります。

感想

不要な初期データの削減はISUCONでよくみる問題だと思います。このあたりもコードをささっと理解できる必要がありそうです。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

ISUCON12予選の復習をしました 5

この記事は何か?

ISUCON12の復習記録です。

第5回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「5 dispenseIDでMySQLを使うのをやめる」を試します。

目次

  1. 「5 dispenseIDでMySQLを使うのをやめる」を試す

1. 「5 dispenseIDでMySQLを使うのをやめる」を試す

発番についてはもともとdispenseIDで生成するのが整数値ではなく文字列値なので、比較的どんな形式でも受け入れるようにベンチマーク/フロントエンドは実装されていました。定番なのはUUIDとかULIDとかになるかと思いますが、普通に ${tenantId}-${unixepochnano} みたいな感じのいくつかのIDの文字列連結でも行けるようになっています。

これは僕も解決できた問題でしたが、「var id int64」を使っていたので、整数値を返す必要があると勘違いしてランダム関数を利用しましたが、文字列値でよいので単純にUUIDを利用すればよかったようです。

go get github.com/google/uuid を実行

@@ -32,6 +32,7 @@ import (
        _ "net/http/pprof"

        "golang.org/x/exp/slices"
+       "github.com/google/uuid"
 )

 const (
@@ -104,6 +105,7 @@ func createTenantDB(id int64) error {

 // システム全体で一意なIDを生成する
 func dispenseID(ctx context.Context) (string, error) {
+       /*
        var id int64
        var lastErr error
        for i := 0; i < 100; i++ {
@@ -126,6 +128,9 @@ func dispenseID(ctx context.Context) (string, error) {
                return fmt.Sprintf("%x", id), nil
        }
        return "", lastErr
+       */
+       uuidObj, _ := uuid.NewUUID()
+       return uuidObj.String(), nil
 }
  • 変更前のスコア
22:04:15.150282 SCORE: 6058 (+6058 0(0%))
  • 変更後のスコア
22:36:19.017277 SCORE: 6390 (+6390 0(0%))

感想

dispenseID()が以下のように「文字列」を返していて、 DBの型も「文字列型」ということをすぐ理解できたら、時間短縮できていたと思います。これもコードを読む機会が少ないことが原因だと反省しています。

var id int64
return fmt.Sprintf("%x", id), nil ← 16進数(a-f小文字)を返す

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)

ISUCON12予選の復習をしました 4

この記事は何か?

ISUCON12予選の復習記録です。

第4回はISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)の「3. アトミック書き込みのためのflockトランザクションに変更する」を試します。

目次

  1. 「4. アトミック書き込みのためのflockトランザクションに変更する」を試す

1. 「4. アトミック書き込みのためのflockトランザクションに変更する」を試す

flockは何のためにあったかというと、Score APICSV入稿でDELETEしてINSERTするという処理をアトミックにするためになぜかトランザクションをしらない人が作った、という設定でした。

トランザクション張らずにflockを外すと、ベンチマーカーの整合性チェックフェーズで「同じscoreを2回入稿して2回目の入稿と同時にRanking APIを叩きまくっていて、DELETEとINSERTのスキマで99行くるはずのところがそれ未満しかこなくて「アトミックになってないぞ!」とエラーにするチェックが走っていて引っかかります。

ともあれ、Scoreの追加をBulk INSERTでやるようになってクエリ数も減ったことですし、DELETE-INSERTの部分をトランザクションにしてflockを外しましょう。残りの箇所はすべて読み取りのみなので、単純に外してOKです。

ということで、flock部分をコメントアウトしました。

@@ -588,11 +588,13 @@ func billingReportByCompetition(ctx context.Context, tenantDB dbOrTx, tenantID i
        }

        // player_scoreを読んでいるときに更新が走ると不整合が起こるのでロックを取得する
+       /*
        fl, err := flockByTenantID(tenantID)
        if err != nil {
                return nil, fmt.Errorf("error flockByTenantID: %w", err)
        }
        defer fl.Close()
+        */

        // スコアを登録した参加者のIDを取得する
        scoredPlayerIDs := []string{}
@@ -1078,11 +1080,13 @@ func competitionScoreHandler(c echo.Context) error {
        }

        // / DELETEしたタイミングで参照が来ると空っぽのランキングになるのでロックする
+       /*
        fl, err := flockByTenantID(v.tenantID)
        if err != nil {
                return fmt.Errorf("error flockByTenantID: %w", err)
        }
        defer fl.Close()
+       */
        var rowNum int64
        playerScoreRows := []PlayerScoreRow{}
        for {
@@ -1291,11 +1295,13 @@ func playerHandler(c echo.Context) error {
        }

        // player_scoreを読んでいるときに更新が走ると不整合が起こるのでロックを取得する
+       /*
        fl, err := flockByTenantID(v.tenantID)
        if err != nil {
                return fmt.Errorf("error flockByTenantID: %w", err)
        }
        defer fl.Close()
+       */
        pss := make([]PlayerScoreRow, 0, len(cs))
        for _, c := range cs {
                ps := PlayerScoreRow{}
@@ -1419,11 +1425,13 @@ func competitionRankingHandler(c echo.Context) error {
        }

        // player_scoreを読んでいるときに更新が走ると不整合が起こるのでロックを取得する
+       /*
        fl, err := flockByTenantID(v.tenantID)
        if err != nil {
                return fmt.Errorf("error flockByTenantID: %w", err)
        }
        defer fl.Close()
+       */
        pss := []PlayerScoreJoinPlayerRow{}
        if err := tenantDB.SelectContext(
  • 変更前のスコア
22:05:24.438783 SCORE: 3862 (+3862 0(0%))
  • 変更後のスコア
22:04:15.150282 SCORE: 6058 (+6058 0(0%))

スコアが2,196改善しました。

感想

flockは明らかに「変な」実装なので、その違和感に瞬時に気づくべきでした。

Go言語の実装力、読む速さ、慣れが足りないのが原因だと思います。それとDBのロックについても詳しくなりたいです。

変な実装は一旦コメントアウトしてベンチの結果を見てみるなどするのも良さそうです。

スコアは改善しましたが、たまにベンチが失敗する場合があるようで、このあたりは今後確認します。

参考

1 ISUCON12 予選の解説 (Node.jsでSQLiteのまま10万点行く方法)