mita2 database life

主にMySQLに関するメモです

TiDB MySQLとの細かな非互換 - SELECT ~ FOR UPDATE SKIP LOCKED 編

SKIP LOCKED はサポートされていないが通ってしまう

TiDB 8.5 時点では SELECT ~ FOR UPDATE SKIP LOCKED はサポートされていません。 しかし、エラーにならずに通ってしまいます。

マニュアルには、「サポートされていない」とは書いてあるのですが、「無視する」とまでは書いてなさそう。

Unsupported features SKIP LOCKED syntax #18207

docs.pingcap.com

MySQL の場合

SKIP LOCKEDMySQL 8.0 でサポートされました。すでにロックされている行を除いて、ロックされてない行のみを返します(ロックを取ります)。 言うまでもありませんが、それ以前のバージョンでは文法エラーになります。

pk1 のレコードのロックを取ります。

mysql> SELECT * FROM tbl;
+----+
| pk |
+----+
|  1 |
|  2 |
|  3 |
+----+
3 rows in set (0.00 sec)

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM tbl WHERE pk = 1 FOR UPDATE;
+----+
| pk |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

pk1 以外のレコードが SELECT されます。

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM tbl FOR UPDATE SKIP LOCKED;
+----+
| pk |
+----+
|  2 |
|  3 |
+----+
2 rows in set (0.00 sec)

TiDB の場合

同じように pk1 のレコードのロックを取ります。

mysql> BEGIN;
Query OK, 0 rows affected (0.01 sec)

mysql> SELECT * FROM tbl WHERE pk = 1 FOR UPDATE \G
*************************** 1. row ***************************
pk: 1
1 row in set (0.00 sec)

別のセッションで、SELECT ~ FOR UPDATE SKIP LOCKED を実行すると、pk1 のレコードも SELECT 出来てしまいます。 サポートしてないなら、エラーにしてほしい。。。。

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

-- ちなみに、tidb_enable_noop_functions は OFF でも同じ
mysql> SELECT * FROM tbl FOR UPDATE SKIP LOCKED \G
*************************** 1. row ***************************
pk: 1
*************************** 2. row ***************************
pk: 2
*************************** 3. row ***************************
pk: 3
3 rows in set (0.00 sec)

-- 確かに pk = 1  の行はロックされている。
mysql> SELECT * FROM tbl WHERE pk = 1 FOR UPDATE \G
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

tidb_txn_mode = pessimistic (デフォルト) での動作確認しています。

MySQL インデックスに主キーを含めると重複する?

インデックスには主キーが含まれる

MySQLセカンダリインデックスには、主キーが自動的に含まれます。セカンダリインデックスを使って特定のレコードを見つけ出す流れは、以下の二段階です。

  1. 検索条件に合うセカンダリインデックスのエントリを見つけます
  2. そのエントリに格納されている主キーを使って、クラスターインデックス(テーブル本体)を検索し、レコード全体を読み取ります

dev.mysql.com

ちなみに、OraclePostgreSQLのような他のデータベースでは、主キーではなく「ROWID」や「TID」と呼ばれる行を識別するための情報が代わりに保存されます。MySQLInnoDB)の仕組みは、クラスターインデックスだからこその特徴と言えます。

インデックスに主キーを指定すると重複する?

さて、ここで一つの疑問が浮かびます。

MySQLセカンダリインデックスは主キーを自動的に含んでいます。では、インデックス定義の末尾に主キーを明示的に加えた場合、インデックス内で主キーの情報は重複して保存されてしまうのでしょうか?

-- 通常のインデックス(主キーは自動追加)
CREATE INDEX idx ON table_name (c1);

-- 主キーを明示的に末尾に含めたインデックス
CREATE INDEX idx_with_pk ON table_name (c1, id); -- id が主キー

🧪 実験:テーブルサイズを比較

もし重複して保存されるなら、後者のインデックスを持つテーブルのサイズは、前者のインデックスを持つテーブルよりも大きくなるはずです。 以下のパターンで確認してみました。

No. テーブル名 インデックスの定義 説明
1 no_redundant_idx_tbl c1 主キーを含めない標準的な定義
2 redundant_idx_asc_tbl c1, id 主キーを昇順で明示的に指定
3 redundant_idx_desc_tbl c1, id DESC 主キーを降順で指定

もし、主キーが重複して保存されるのであれば、No.1 と No.2 のテーブルサイズに差がでるはずですが、 すべてのテーブルサイズが完全に一致しました(ALTER TABLE xxx ENGINE=InnoDB で再構築済みのサイズ)。

$ sudo ls -al /var/lib/mysql/t
total 1179660
drwxr-x---. 2 mysql mysql       105 Oct 24 11:41 .
drwxr-x--x. 8 mysql mysql      4096 Oct 24 09:30 ..
-rw-r-----. 1 mysql mysql 402653184 Oct 24 09:09 no_redundant_idx_tbl.ibd
-rw-r-----. 1 mysql mysql 402653184 Oct 24 11:41 redundant_idx_asc_tbl.ibd
-rw-r-----. 1 mysql mysql 402653184 Oct 24 11:41 redundant_idx_desc_tbl.ibd

この結果から、MySQLInnoDB)は、セカンダリインデックスの末尾に主キーが指定された場合、主キーが重複して保存されるのを自動的に回避していることが分かります。

明示的な主キー指定が意味を持つケース

では、主キーを明示的にインデックスに含める行為は、全く無意味なのでしょうか?

→ いいえ。降順インデックスを利用するケースでは、明示的に指定する意味があります。

明示的に主キーを降順で定義したインデックス (No.3) は、ORDER BY c1, id DESC を最適化可能です(ExtraUsing filesort が出力されてないことからソートがインデックスで解決されていることがわかる)。

mysql> EXPLAIN select * from redundant_idx_desc_tbl order by c1, id DESC limit 10;
+----+-------------+------------------------+------------+-------+---------------+-----------------+---------+------+------+----------+-------+
| id | select_type | table                  | partitions | type  | possible_keys | key             | key_len | ref  | rows | filtered | Extra |
+----+-------------+------------------------+------------+-------+---------------+-----------------+---------+------+------+----------+-------+
|  1 | SIMPLE      | redundant_idx_desc_tbl | NULL       | index | NULL          | redundant_idx_2 | 1027    | NULL |   10 |   100.00 | NULL  |
+----+-------------+------------------------+------------+-------+---------------+-----------------+---------+------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)

一方で、c1のみのインデックス(No.1)、または主キーを昇順で定義したインデックス(No.2)では、id DESC の部分をインデックスでカバーできず、filesort が発生します。

-- redundant_idx_asc_tbl
mysql> EXPLAIN select * from redundant_idx_asc_tbl order by c1, id DESC limit 10;
+----+-------------+-----------------------+------------+------+---------------+------+---------+------+---------+----------+----------------+
| id | select_type | table                 | partitions | type | possible_keys | key  | key_len | ref  | rows    | filtered | Extra          |
+----+-------------+-----------------------+------------+------+---------------+------+---------+------+---------+----------+----------------+
|  1 | SIMPLE      | redundant_idx_asc_tbl | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 2082711 |   100.00 | Using filesort |
+----+-------------+-----------------------+------------+------+---------------+------+---------+------+---------+----------+----------------+
1 row in set, 1 warning (0.01 sec)

ソースコード

重複が排除されるよう制御しているソースコードを探してみます。 storage/innobase/dict/dict0dict.ccdict_index_build_internal_non_clust にありました (MySQL 8.0.44) 。 indexed[field->col->ind] = true でインデックスに含まれているカラムにフラグを立てて、主キーのうち、フラグが立っていないカラムのみ dict_index_add_col を呼んでいます。

** Builds the internal dictionary cache representation for a non-clustered
 index, containing also system fields not defined by the user.
 @return own: the internal representation of the non-clustered index */
static dict_index_t *dict_index_build_internal_non_clust(
    const dict_table_t *table, /*!< in: table */
    dict_index_t *index)       /*!< in: user representation of
                               a non-clustered index */
{


<snip>

  /* Mark the table columns already contained in new_index */
  for (i = 0; i < new_index->n_def; i++) {
    field = new_index->get_field(i);

    if (field->col->is_virtual()) {
      continue;
    }

    /* If there is only a prefix of the column in the index
    field, do not mark the column as contained in the index */

    if (field->prefix_len == 0) {
      indexed[field->col->ind] = true;
    }
  }

  /* Add to new_index the columns necessary to determine the clustered
  index entry uniquely */

  for (i = 0; i < clust_index->n_uniq; i++) {
    field = clust_index->get_field(i);

    if (!indexed[field->col->ind]) {
      dict_index_add_col(new_index, table, field->col, field->prefix_len,
                         field->is_ascending);
    } else if (dict_index_is_spatial(index)) {
      /*For spatial index, we still need to add the
      field to index. */
      dict_index_add_col(new_index, table, field->col, field->prefix_len,
                         field->is_ascending);
    }
  }

まとめ

MySQLセカンダリインデックスについて、今回の実験で分かったことは以下の2点です。

  • インデックスの末尾に主キーを指定したとしても、主キーが重複してインデックスに含まれることはない。主キーを末尾に明示的に書いても書かなくても同じインデックスが作られる。
  • ただし、降順インデックスを活用したい場合、明示的に主キーを含めるのは意味がある。

TiDB metadata lock で DDL が待たされた時の調査方法

前提

本エントリーは、tidb_enable_metadata_lock が ON であることを前提としています。

mysql> SELECT @@transaction_isolation, @@tidb_enable_metadata_lock, @@version;
+-------------------------+-----------------------------+--------------------+
| @@transaction_isolation | @@tidb_enable_metadata_lock | @@version          |
+-------------------------+-----------------------------+--------------------+
| REPEATABLE-READ         |                           1 | 8.0.11-TiDB-v8.1.2 |
+-------------------------+-----------------------------+--------------------+
1 row in set (0.00 sec)

DDLをブロックしているトランザクションの調査方法

TiDB にも MySQL 同様に metadata lock があり、DDLがクエリによってブロックされることがあります。

mysql.tidb_mdl_view でブロックしているトランザクションを確認できます。 metadata lock が競合せず、ブロックが発生していない場合は、何も表示されません。

tidb_mdl_view は、実行済みのクエリも含めて、ブロックしているトランザクションに含まれる全てのクエリを SQL_DIGESTS に出力してくれます。これは非常に便利ですね。 start_time も、直近のクエリの開始時間ではなく、トランザクションの開始時間です。

mysql>  SELECT * FROM mysql.tidb_mdl_view;
+--------+---------+------------+--------------------------------+------------+----------------------------+-------------------------------------------+
| job_id | db_name | table_name | query                          | session_id | start_time                 | SQL_DIGESTS                               |
+--------+---------+------------+--------------------------------+------------+----------------------------+-------------------------------------------+
|    108 | t       | t          | ALTER TABLE t ADD INDEX idx(a) | 3212836870 | 2025-09-11 21:01:30.919000 | ["begin","select `a` , now ( ) from `t`"] |
+--------+---------+------------+--------------------------------+------------+----------------------------+-------------------------------------------+
1 row in set (0.00 sec)

さて、ここで、追加のトランザクションを実行します。 なお、MySQL では MDL 待ち状態で、追加のトランザクションを実行すると、そのトランザクションは待たされますが、TiDB では DDL を追い越して、トランザクションを実行することができます(その代わり、後続のトランザクションが先行するDDLをブロックします)。

mita2db.hateblo.jp

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT a, NOW() AS 2ndtry FROM t;
+------+---------------------+
| a    | 2ndtry              |
+------+---------------------+
|    1 | 2025-09-11 21:04:27 |
+------+---------------------+
1 row in set (0.00 sec)

tidb_mdl_view にも行が追加されることがわかりました。

mysql> mysql>  SELECT * FROM mysql.tidb_mdl_view;
+--------+---------+------------+--------------------------------+------------+----------------------------+-----------------------------------------------+
| job_id | db_name | table_name | query                          | session_id | start_time                 | SQL_DIGESTS                                   |
+--------+---------+------------+--------------------------------+------------+----------------------------+-----------------------------------------------+
|    108 | t       | t          | ALTER TABLE t ADD INDEX idx(a) | 3212836870 | 2025-09-11 21:01:30.919000 | ["begin","select `a` , now ( ) from `t`"]     |
|    108 | t       | t          | ALTER TABLE t ADD INDEX idx(a) | 3212836876 | 2025-09-11 21:04:26.073000 | ["select `a` , now ( ) as `2ndtry` from `t`"] |
+--------+---------+------------+--------------------------------+------------+----------------------------+-----------------------------------------------+
2 rows in set (0.00 sec)

トランザクションをコミット/ロールバックすると、tidb_mdl_view から消えます(そしてDDLの実行が開始されます)。

db tech showcase 2025 (1日目) に参加しました

毎年恒例の db tech showcase に今年も参加してきました。 DB界隈のみなさんと情報交換できて非常に有意義でした!

www.db-tech-showcase.com

以下、参加したセッションのメモ。書き間違いとかニュアンスの違いあったらごめんなさい。

1. Spanner 内部シャーディング 徹底解剖 2025

Spanner の最新情報

  • Spanner はRelational モデルだけでなく複数のデータモデルに対応してきた

  • 全文検索もできるようになりました!

    • 従来のRDBにおける全文検索の課題

      • リソース食いがち
      • トークナイザーの品質不足
    • Spannerでは Google の優秀なトークナイザが使える

      • とうきょうと→東京都 がヒット
      • ぐーぐる→Google,グーグル がヒット

分散DBにとってシャードとは

  • 分散DBはテーブルを分割し、複製する

    • 分割 → 性能向上
    • 複製 → 可用性と耐障害性のため
  • シャーディング(分割)と複製(レプリカ)はあらゆる場面で使われきた

  • 分散DB と従来の「シャーディング+レプリカ」の違い

  • Tablet, Shard, Partition, Region, Range, Chunk など内部的なシャードの名称はいろいろ

    • Spanner では Split と呼ぶ

Spanner の Split の基礎

  • Split ごとに同期レプリケーション(Paxos ベース)をしている

    • Leader は異なるゾーン(AZ) に分散配置され、耐障害性が確保されている
  • Spanner の「サーバ」とは

    • Spannerを1ノード増やすと3サーバ追加される
    • サーバはVMではなく軽量なコンテナ
    • Splitはサーバに割り当てられる、1つのサーバは1つ以上の Split の面倒を見る
  • Split はサーバ間を移動可能

    • 負荷の高いサーバから低いサーバへ割り当てが変更される
    • SplitはColossus(共有ストレージ)に置かれる、サーバに直接保存されるわけではない
    • 実際にデータが移動するわけではない

Split とは何か?

  • テーブルのデータは Primary Key の辞書順で並んでいる

    • Split は テーブルの特定のレンジを切り出したもの
    • Split のレンジは均一ではない
  • 分割する境界を Split Point と呼ぶ

  • スプリットはテーブル単位でできる?

    • No
    • DB内の全てのテーブルを結合したものを分割する
    • テーブルとテーブルをまたがった Split が出来ることもある
    • 作りたてのテーブルには分割点がないため、複数テーブルであってもSplit は 1つ

Split の変化の契機

  • SQLプロファイルの並列数から Split 数が推定できるウラワザがある

  • Split の 移動

    • Split とサーバの割り当ての変更
    • 軽めの処理
  • Split の 分割

    • いわゆるリシャード
    • 比較的 重い処理
    • ロックを一瞬取って、分割する
      • Tail latency が若干高くなる
      • 自動リトライでカバーされるため、エラーにはならない
    • 自動分割
      • 負荷ベース
    • 手動分割
      • 初期ローンチ時などあらかじめ負荷のため Split を細かく分割しておきたい場合に利用
  • Split 統計

    • 各 Split の負荷(スコア)が見れる画面
    • スコア50以上は分割される候補
    • 単一スプリット
  • 手動分割

    • 1ノードあたり、20個を目安に事前に分けておくと良い
  • Split の 結合

    • Split の有効期限 は 2日
    • 再結合可能になる期限
    • 必ず結合されるとは限らない
  • 手動の大量分割・移動には時間かかる

    • Spannerはゆるやかに Split を分割処理しようとする
    • ドキュメントでは 7日~12時間前までに実行開始しましょう と書かれている

感想

@takabow さんのセッション。いつもどおり、わかりやすく、スッと入ってくる説明でした。Spannerは非常に賢く Split を管理していて、もしもホットスポットが発生するような設計をしてしまっても、それなりの規模までなら耐えられてしまう印象を受けました。このあたりの肌感覚を得られたのは大きかったです。

2. Amazon Aurora DSQLの仕組みと特徴

事実上無制限のスケーラビリティ

  • コンピュート / コミット / ストレージ は個別に拡張できる
  • コンピュートの READ / WRITE の比率も動的に変更できる

  • DSQLのエンドポイントはVPC の外にある

    • VPC のNW制限をケアする必要なし
  • 認証はIAM認証のみ

インフラ管理の排除

  • エンドポイントしか見えない

アーキテクチャ

書き込み時の動作

  • COMMIT を打つと ADJUDICATOR へ情報が連携される

    • 競合の検知
    • 競合したらCOMMITがABORTする
  • ジャーナルに書き込んだら、クライアントにCOMMIT OKが返る

    • ストレージに書き込む前に、クライアントにOKが返る

読み取りの動作

  • クエリープロセッサーが直接ストレージを読む

    • ADJUDICATOR,ジャーナル,クロスバーは読み取りの際は利用されない
  • ジャーナル にしかない(ストレージにまだ書かれてない)データは抜けるのでは?

    • 対策がしてある
    • ストレージは今どこまでトランザクションが適用されているか知っている
    • 必要に応じてジャーナルから書き込まれるのをごく一瞬待ってから、応答する

ビジネス継続性 - 可用性

  • アクティブ・アクティブのマルチリージョン構成も可能
  • シングルリージョン構成の場合も、マルチAZ構成で高い冗長性

  • バックアップ

    • AWSバックアップサービスでバックアップする
  • メジャーバージョンアップ

  • 方法は未定だが、ダウンタイムは発生しない
  • メジャーアップのタイミングはコントロールできる

分散データベースとしての性能最適化やトランザクションの特徴

  • トランザクション開始〜コミット直前までは、リージョン内/AZ内の通信で完結する

    • COMMITされたら、リージョン間で通信して整合性をとる
  • 読み取りはローカルコピーから読み込まれる、リージョンやAZをまたがない

  • トランザクション分離レベル Snapshot Isolation (PostgreSQLのRepeatable Read相当) をサポート

    • たとえ、Read Commited にしたとしてもDSQLにおいては恩恵はない

料金

  • DPU (Distributed Processng Unit)
    • 机上での見積もりが難しい...
    • 1行70byteの単表に対してワークロードを流した料金:
      • 5000 PK SELECT/sec, 50 q/sec * {INSERT,UPDATE,DELETE}
      • 24h で $26 @ 東京リージョン
  • Storage (GB-month)
  • Data transfer / Backup and Restore

トランザクション管理

  • OCC (楽観的同時実行制御)

    • UPDATEやINSERTでロック待ちは発生しない一方
    • COMMIT 時点で ABORT が発生する
  • カタログキャッシュ

感想

新久保さんのセッション。初めてDSQLの詳しい話をききました。アーキテクチャの詳細が知れて非常に参考になりました。 アーキテクチャの違いが既存の分散DBと比較したときに、どのような差として現れてくるのか、今後、より調査してみたいところです。 本題からはずれるんですが、「DSQLはIAM認証のみをサポート」というのを聞いて、今後、IAM認証を標準的に利用していく必要性を感じました。

3. 河合塾が抱えていたDB運用におけるジレンマの改善 〜DBの可視化で何が変わったのか?〜

データベースの運用課題

  • 統合DBに負荷が集中

    • 受付、塾生ポータル、校舎業務系...
  • Oracle RAC 2台構成

  • 2022/04月から慢性的にCPU負荷が高い状態に...

  • 負荷対応に工数・時間がかかってしまう

    • AWRレポートの発行だったり、ログ取りといった、煩雑な手動作業

課題に対するアプローチの検討

  • 現状課題:トラブルシュートの手間を減らしたい

    • ありたい姿: ログの収集や保全を自動的に行える
  • 現状課題: 一部ログやメモリ上のデータなどは情報が一定期間で消失してしまう。過去の運用情報が把握できない。

    • ありたい姿: 長期間データを保全できる仕組み
  • 現状課題:時間解像度が低い

    • ありたい姿:高頻度に情報を収集したい

比較検討

  • Oracle Diag Pack、 Oracle Stats Pack
    • 可視化のIFがない
  • Database Performance Analyzer

    • SQLで取れる情報しか扱えない
  • (採用) MaxGauge for Oracle

    • 時間解像度が高い(秒単位)
    • メモリから直接情報を収集 (Direct Memory Access)
    • 買い切りライセンスモデル

MaxGaugeを導入して解決した課題

  • 稼働状態が時系列で見える化できた

    • 前回との比較、先月との比較、といった傾向分析もできるようになった
  • 負荷をかけているSQL特定し、システム開発ベンダーに改善を依頼しやすくなった

  • データベースチーム以外にもダッシュボードを提供できるようになった (民主化)

感想

わかりやすい説明で、可視化の重要性を再認識できました。 メモリから直接情報を収集できるのはすごい (Oracleソースコードが公開されてないのに、どうやって実現しているんでしょう!?)。 プロセスがハングアップしてても情報収集できるとか。

4. Aurora Audit Log 深掘り実践 〜Insight SQL Testing を通じた運用改善への道〜

Aurora 3.0 バージョンアップ対応

  • 150クラスターのv2→v3のバージョンアップを完了

  • バージョンアップを支えた内製ツール

    • DB Catalog
      • Schema などの情報をためているレポジトリ
    • カスタムアップグレードチェッカー
      • mysqlsh の upgrade_checker をベースに自社独自の視点を追加
  • アップグレードチェッカーをプロダクトサイトに提供

    • DBREはあくまでもリアクティブに対応する方針で進めていた
  • プロダクトサイドからは専門家(DBRE)により踏み込んだチェックをしてほしという要求があった

    • クエリリプレイのソリューションを検討した

Insight SQL Testing

  • Audit Log をリプレイするソリューション

  • 移行元と移行先、両方にクエリを投げて比較してくれる

    • エラー検知
    • パフォーマンス劣化の検知
  • 3.3億行をリプレイした

    • 問題があると検知されたクエリ:486万クエリ
  • 減ったとはいえ 486万クエリもある

  • 対応が必要なクエリを絞り込む作業をした

    • クエリのノーマライズ、既知の問題のフィルター
    • 実行タイミングに依存するクエリ(現在時刻が関係する処理、ORDER BY未指定 など)の除外
    • パフォーマンス劣化は劣化度合いにより足切りした

Aurora Audit Log とは

  • Aurora Audit Log は MariaDB Audit Plugin ベース
  • メリット
    • Aurora 標準機能で機能利用に追加コストなし
    • AWS Console 、 IaC で管理できる
    • セキュリティ監査への対応
  • デメリット

    • クエリ全文が記録されることによる個人情報リスク
    • ログの保管コスト
  • 活用例

    • Retcode
      • 0 以外を検索してトラブルシュートに活用
    • Connection ID
      • その時間のセッション数を類推できる
      • 該当セッションの継続時間
  • Audit Log の出力順序は保証されない

    • QUERY, CONNECT, DISCONNECT みたいな矛盾した順番で出てくることもある
    • パースするときに注意が必要
  • Insight SQL Testing への連携方法

  • S3 へ 時間指定して export
  • SQL Testing に取り込める形式(CSV)に変換するツールが提供されている
  • ログの境目で CONNECT/DISCONNECT がぬけてるケースもちゃんとハンドリングされている

Audit Log を活用した内製ツール

  • Audit Log に向き合ってみて、普段の運用にも活用できそうだと思った。

  • Audit Log をパースして MySQL に入れるツールを作成

    • 分析ができるようになった
  • 利用例

    • 1本あたりのクエリのコストを見積もるのも出来そう
  • 正確な情報を把握する重要性

感想

@_awache さんのセッション。あわっちさんの人柄あふれる内容で、自分も「プロアクティブにプロダクト側を支援していかなかくては。」と感じさせる内容でした。

5. Aurora から Spanner へ Time Tree

移行の背景

  • データ量の継続的な増加
  • Aurora の様々な上限に達しそう

  • 利用状況

    • 65 Million User, 27 Billion Records
  • 将来を考えるとさまざまな上限が不十分

  • プロジェクトのタイムライン

    • 2018: 課題認識
    • 2019: チーム内で課題共有
    • 2021/06: クリティカルな課題して社内共有
    • 2023/01: プロジェクト稼働開始
    • 2024/01: 移行作業開始
    • 2025/01: 移行完了

Spanner に決めた理由

  • フルマネージド
  • Google での運用実績
  • オートスケーリング
  • 他の Google Cloud サービスとの連携
  • Google Workspace との連携
    • Google WorkspaceのIDでSpannerへのアクセス管理ができる

Pre マイグレーション

  • MySQL アップデート (Aurora v2 → v3)

    • 事前のスキーマ整理のためインスタントDDLを利用したい。
    • 照合順序の見直しなど
  • スキーマ整理

    • Spannerでインターリーブを利用予定だったので、親子関係を設定できるようにカラム追加
  • クレンジング

    • 移行に向けて不要なデータの整理

マイグレーション

  • spanner-migration-tool (SMT) を利用した

  • source データベースと dist データベースのスキーマ情報をマッピングしたファイルを作成

  • 自動的にデータ移行のパイプラインが作成される

    • DataStream -> Cloud Storage -> PubSub -> ComputeEngine -> Spanner

SMTのカスタムロジック

  • MySQLのAUTO_INCREMENTを利用していた

    • Spanner へ移行するにあたり、 AUTO_INCREMENT に Bit Reverse Seq を適用する必要があった
    • SMTで用意されているDataflowにカスタムロジックを入れた
  • しかし、SMTでは親子関係が考慮されずにエラーに

    • Google から改良バージョンが提供されて解決
  • SMTの新バージョンに不具合があり、実際は従来のバージョンで移行した

    • SMTの新バージョンに問題があった場合に備えていた
    • 従来のSMTを読み解いて、親子関係を考慮したものをバックアッププランとして事前準備

移行後

  • 運用コスト

    • ゼロになった
    • バージョンアップも自動で勝手にやってくれる
    • オートスケール
  • サービス開発コスト

    • 学習コストが増
  • 金銭的コスト

    • 若干増加したが、運用コストを考えるとトータルコストは削減できた
  • セキュリティ

    • Google ID との連携により改善
  • パフォーマンス

    • Spanner のシャーディングによるレイテンシ劣化は影響なかった
  • タイミング

    • サービスで様々な企画が進められる前に実行できてよかった
  • 社内文化への影響

    • SpannerはGraph や 全文検索など様々な検索ができる
    • Spannerの勉強会を実施し、結果的にエンジニアがより積極的にサービス改善に関わる文化が情勢されてきた

感想

Time Tree 金井 さんのセッション。 プロジェクトマネージメントやステイクホルダーとの目線合わせなど、技術的な部分以外の重要性も認識させてくれる内容でした。

LT

DBA/DBREしくじり先生 ~やっちゃったな実録集~

speakerdeck.com

@tomomo1015 さんの過去のやらかしの振り返りLT。1つ1つの事例をサクッと説明されてたんですが、実際はもっといろいろあったんだろうな... と感じるLTでした。どこかでもっと詳しく話してほしい(笑)

DBのスキルで生き残る技術 - AI時代におけるテーブル設計の勘所

@soudai1025 さんの説明はいつも、心に刺さるんですよね。ということで、ワールドトリガーを読み始めました(おい)

speakerdeck.com

いまさらMySQLの非同期レプリケーションでのHAの難しさについて考える

@yoku0825 さんの MySQLでしっかりとしたHAを組むことの難しさを訴えるLT。ただただ、共感しかない。

speakerdeck.com

Gemini Code Assist に プルリク上 で DDL をレビューさせる

Gemini Code Assist に DDL をレビューさせてみました。

レビューさせるDDL

CREATE TABLE users (id int, user_name TEXT, create datetime);
CREATE TABLE user_items (id int, user_id VARCHAR(10), itemName TEXT, status VARCHAR(250), created datetime);

以下のような問題点を意図的に仕込みました。はたして、Gemini Code Assist はうまく指摘してくれるのでしょうか?

  1. Primary Key が指定されていない
  2. user_nameNOT NULL の指定漏れ
  3. 予約語 create が使用されてしまっている
  4. usersiduser_itemsuser_id の型の不一致
  5. userscreateuser_itemscreated の表記揺れ
  6. 命名規則 が CamelCase と SnakeCase の混在
  7. 不必要に大きな型 (user_name, status)

Gemini Code Assist を Github にインストール

developers.google.com

何も指示を与えない状態

とりあえず、何も指示をあたえず、デフォルトの状態でレビューさせてみます。 PRを作ると自動的に Gemini Code Assist がレビューを開始し、コメントをつけてくれます。1分程度で、結果が返ってきました。

https://github.com/samitani/DDLreview/pull/1

Gemini Code Assist

結果は以下でした。このリポジトリDDLしかありません。情報が限られた中でのレビューということを考えると、かなり良い感じだと感じました。 正直、この状態でも十分満足です。

  1. ✅ Primary Key が指定されていない
  2. user_nameNOT NULL の指定漏れ
  3. 予約語 create が使用されてしまっている
  4. usersiduser_itemsuser_id の型の不一致
  5. userscreateuser_itemscreated の表記揺れ
  6. 命名規則 が CamelCase と SnakeCase の混在
  7. ❌ 不必要に大きな型 (user_name, status)

それ以外にも、外部キー制約が必要な点が指摘されてます。

型が妥当かどうかは、ドメイン知識がないと判断が難しいのでしょう。アプリケーションのコードが一緒にコミットされてれば、検知してくれるかもしれません。

指示を与えてあらためてレビューさせる

.gemini/styleguide.md に指示を記述できます。 適当に指示を追加してレビューさせてみましょう。

% cat .gemini/styleguide.md
日本語で回答してください。

# DDL をレビューするときのポイント

* 外部キー制約は任意とします。ただし、既存のテーブルで外部キー制約をすでに利用している場合は、常に外部キー制約を利用するようにしてください。
* カラム名の表記ゆれがないか確認してください。
* TEXT型が利用されている場合、VARCHAR型を検討してください。
* フラグやステータスといった値のバリエーションが固定されているカラムにはENUM型を検討してください。

https://github.com/samitani/DDLreview/pull/2

  1. ✅ Primary Key が指定されていない
  2. user_nameNOT NULL の指定漏れ
  3. 予約語 create が使用されてしまっている
  4. usersiduser_itemsuser_id の型の不一致
  5. userscreateuser_itemscreated の表記揺れ
  6. 命名規則 が CamelCase と SnakeCase の混在
  7. ✅不必要に大きな型 (user_name, status)

7 の型については指摘が入るようになりました。 5 の表記ゆれは今回もスルーされました。もうちょっと詳しく指示しないとダメかもしれません。

初期状態では 外部キー制約を This is crucial と強く利用を勧めてきてましたが、指示を考慮して、今回は マイルドな指摘へと変化してます。

データ型を揃えることで、データ整合性を保つための外部キー制約 (FOREIGN KEY (user_id) REFERENCES users(id)) の追加も可能になります。

指示を改良してリトライ

表記ゆれを見逃さないように「似たカラム名がある場合、表記がゆれていないか確認してください」と怪しいケースを積極的にひろうように指示してみます。 また、外部キーを任意としたので、代わりにインデックスの有無を確認するよう指示を足しました。

% git diff
diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md
index c110d42..9038135 100644
--- a/.gemini/styleguide.md
+++ b/.gemini/styleguide.md
@@ -3,6 +3,7 @@
 # DDL をレビューするときのポイント

 * 外部キー制約は任意とします。ただし、既存のテーブルで外部キー制約をすでに利用している場合は、常に外部キー制約を利用するようにしてください。
-* カラム名の表記ゆれがないか確認してください。
+* キーとなるカラムにインデックスが追加されているか確認してください。
+* 似たカラム名がある場合、表記がゆれていないか確認してください。
 * TEXT型が利用されている場合、VARCHAR型を検討してください。
 * フラグやステータスといった値のバリエーションが固定されているカラムにはENUM型を検討してください。

https://github.com/samitani/DDLreview/pull/3

今回は表記ゆれもバッチリ検知してくれました 👏

中 (Medium): created カラム名も users テーブルと合わせて created_at に統一すると、スキーマ全体の一貫性が高まります。

高 (High): 外部キー候補である user_id カラムにインデックスがありません。検索パフォーマンス向上のため、インデックスの追加を強く推奨します。1

Gemini Code Assist すばらしい。

go-sysbench のカスタムシナリオが書きやすくなりました

go-sysbench

go-sysbench は Go 言語で書かれた、sysbench のクローンです。複雑なカスタムシナリオを sysbench より簡単に作れることを目的として作りました。

github.com

sysbench はシンプルで、非常に使いやすいベンチマークツールで、長年愛用してきました。Lua 言語でカスタムシナリオを書くこともできます。 ただ、Lua 言語で複雑なカスタムシナリオを書くのは難しいと感じてました(Lua 言語に慣れていないということもあると思います)。 そこで、使い勝手や出力は sysbench のまま、より柔軟性の高い Go言語で sysbench のクローンを作りました。

$ go-sysbench --help
Usage:
  go-sysbench [options]... [oltp_read_only|oltp_read_write] [prepare|run]

Application Options:
      --threads=                        number of threads to use (default: 1)
      --events=                         limit for total number of events (default: 0)
      --time=                           limit for total execution time in seconds (default: 10)
      --report-interval=                periodically report intermediate statistics with a specified interval in seconds. 0 disables intermediate reports (default: 0)
      --histogram=[on|off]              print latency histogram in report (default: off)
      --percentile=                     percentile to calculate in latency statistics (1-100) (default: 95)
      --tables=                         number of tables (default: 1)
      --table_size=                     number of rows per table (default: 10000)
      --table-size=                     alias of --table_size
      --db-driver=[mysql|pgsql|spanner] specifies database driver to use (default: mysql)
      --db-ps-mode=[auto|disable]       prepared statements usage mode (default: auto)
      --version                         show version

MySQL:
      --mysql-host=                     MySQL server host (default: localhost)
      --mysql-port=                     MySQL server port (default: 3306)
      --mysql-user=                     MySQL user (default: sbtest)
      --mysql-password=                 MySQL password [$MYSQL_PWD]
      --mysql-db=                       MySQL database name (default: sbtest)
      --mysql-ssl=[on|off]              use SSL connections (default: off)
      --mysql-ignore-errors=            list of errors to ignore, or "all" (default: 1213,1020,1205)

PostgreSQL:
      --pgsql-host=                     PostgreSQL server host (default: localhost)
      --pgsql-port=                     PostgreSQL server port (default: 5432)
      --pgsql-user=                     PostgreSQL user (default: sbtest)
      --pgsql-password=                 PostgreSQL password [$PGPASSWORD]
      --pgsql-db=                       PostgreSQL database name (default: sbtest)
      --pgsql-ssl=[on|off]              use SSL connections (default: off)
      --pgsql-ignore-errors=            list of errors to ignore, or "all" (default: 40P01,23505,40001)

Spanner:
      --spanner-project=                Spanner Google Cloud project name
      --spanner-instance=               Spanner instance id
      --spanner-db=                     Spanner database name (default: sbtest)

Help Options:
  -h, --help                            Show this help message

今回修正した内容

go-sysbench の初期実装は、シナリオとベンチマーカー本体のロジックが十分整理できておらず、少しカスタムしにくい実装でした。 今回、インターフェイスを整理して、完全に独立したライブラリとして使えるようにしました。

使い方

github.com/samitani/go-sysbench を import して、計測したい処理だけ記述すればOKです。

  • Init()でDBの接続など、準備を行い、Event() 関数に計測したい処理を記述します。
  • Event() 関数は Read/Write/Others/IgnoreError 数を返す必要があります、これらの数は集計され、ベンチマーク結果に出力されます。
  • Event() 関数が1回実行されると、1回トランザクションが成功したとみなされます(結果のtransactions がインクリメントされます)。
package main

import (
        "context"
        "fmt"
        "os"

        "database/sql"

        _ "github.com/go-sql-driver/mysql"

        "github.com/samitani/go-sysbench"
)

type CustomBenchmark struct {
        db *sql.DB
}

// when Runner.Prepare(), Runner.Run() is called, Init() is called once in advance.
func (b *CustomBenchmark) Init(ctx context.Context) error {
        db, err := sql.Open("mysql", "root:password@/my_database")
        if err != nil {
                return err
        }

        err = db.Ping()
        if err != nil {
                return err
        }

        b.db = db
        return nil
}

// when Runner.Prepare(), Runner.Run() is called, Done() is called once at the end.
func (b *CustomBenchmark) Done() error {
        b.db.Close()
        return nil
}

// when Runner.Prepare() is called, Prepare() is called once.
func (b *CustomBenchmark) Prepare(ctx context.Context) error {
        // nothing to do
        return nil
}

// when Runner.Run() is called, PreEvent() is called once before event loop.
func (b *CustomBenchmark) PreEvent(ctx context.Context) error {
        // nothing to do
        return nil
}

// when Runner.Run() is called, Event() is called in a loop
func (b *CustomBenchmark) Event(ctx context.Context) (numReads, numWrites, numOthers, numIgnoredErros uint64, err error) {
        var readCount uint64 = 0
        var writeCount uint64 = 0

        // something you want to measure
        for i := 0; i < 5; i++ {
                rows, err := b.db.QueryContext(ctx, "SELECT NOW()")
                if err != nil {
                        return readCount, 0, 0, 0, err
                }
                defer rows.Close()

                // fetch rows from server
                for rows.Next() {
                }

                readCount = readCount + 1
        }

        return readCount, writeCount, 0, 0, nil
}

func main() {
        bench := &CustomBenchmark{}

        r := sysbench.NewRunner(&sysbench.RunnerOpts{
                Threads:        10,
                Events:         0,
                Time:           60,
                ReportInterval: 1,
                Histogram:      "on",
                Percentile:     95,
        }, bench)

        if err := r.Run(); err != nil {
                fmt.Println(err)
                os.Exit(1)
        }
}

そして、結果は見慣れた sysbench の形式で出力されます。

$ ./main
Running the test with following options:
Number of threads: 10
Report intermediate results every 1 second(s)

[ 1s ] thds: 10 tps: 5072.00 qps: 25360.00 (r/w/o: 25360.00/0.00/0.00) lat (ms,95%): 2.91 err/s 0.00 reconn/s: N/A
[ 2s ] thds: 10 tps: 5162.00 qps: 25810.00 (r/w/o: 25810.00/0.00/0.00) lat (ms,95%): 2.91 err/s 0.00 reconn/s: N/A
[ 3s ] thds: 10 tps: 5180.00 qps: 25900.00 (r/w/o: 25900.00/0.00/0.00) lat (ms,95%): 2.86 err/s 0.00 reconn/s: N/A
[ 4s ] thds: 10 tps: 5387.00 qps: 26935.00 (r/w/o: 26935.00/0.00/0.00) lat (ms,95%): 2.76 err/s 0.00 reconn/s: N/A
[ 5s ] thds: 10 tps: 5340.00 qps: 26700.00 (r/w/o: 26700.00/0.00/0.00) lat (ms,95%): 2.86 err/s 0.00 reconn/s: N/A
[ 6s ] thds: 10 tps: 5199.00 qps: 25995.00 (r/w/o: 25995.00/0.00/0.00) lat (ms,95%): 2.86 err/s 0.00 reconn/s: N/A
[ 7s ] thds: 10 tps: 5422.00 qps: 27110.00 (r/w/o: 27110.00/0.00/0.00) lat (ms,95%): 2.76 err/s 0.00 reconn/s: N/A
[ 8s ] thds: 10 tps: 5193.00 qps: 25965.00 (r/w/o: 25965.00/0.00/0.00) lat (ms,95%): 2.86 err/s 0.00 reconn/s: N/A
[ 9s ] thds: 10 tps: 5283.00 qps: 26415.00 (r/w/o: 26415.00/0.00/0.00) lat (ms,95%): 2.81 err/s 0.00 reconn/s: N/A
<snip>
Latency histogram (values are in milliseconds)
       value  ------------- distribution ------------- count
       0.127 |                                         1
       0.312 |                                         1
       0.318 |                                         1
       0.448 |                                         1
       0.546 |                                         1
       0.576 |                                         1
SQL statistics:
    queries performed:
        read:                            1572640
        write:                           0
        other:                           0
        total:                           1572640
    transactions:                        314535 (5242.20 per sec.)
    queries:                             1572640 (26210.44 per sec.)
    ignored errors:                      0      (0.00 per sec.)
    reconnects:                          N/A    (N/A per sec.)

General statistics:
    total time:                          60.0005s
    total number of events:              314535

Latency (ms):
         min:                                    0.13
         avg:                                    1.91
         max:                                    8.12
         95th percentile:                        2.86
         sum:                               599624.51

Threads fairness (Event distribution by threads):
    events (avg/stddev):           31453.5000/69.05
    execution time (avg/stddev):   59.9625/0.00

より、実用的な例は oltp_read_only, oltp_read_write の実装をみてください。

MySQL SYSTEM_USER 権限を持ったユーザの操作には SYSTEM_USER 権限 が必要

動的権限と静的権限

「動的権限は、従来の静的権限が細分化されたもの。静的権限を持っていれば、動的権限はREVOKEしても影響はない」と考えていました。 例えば、SUPER 権限 (静的) を持っていれば、BINLOG_ADMIN 権限 (動的) がなくとも、バイナリログをパージできます。

mysql> SHOW GRANTS;
+-------------------------------------------+
| Grants for super@localhost                |
+-------------------------------------------+
| GRANT SUPER ON *.* TO `super`@`localhost` |
+-------------------------------------------+
1 row in set (0.00 sec)

mysql>  PURGE BINARY LOGS TO 'binlog.000008';
Query OK, 0 rows affected (0.01 sec)

しかし、SYSTEM_USER 権限は、静的権限では満たせない仕様がありました。

dev.mysql.com

SYSTEM_USER 権限によるアカウント保護

簡単に言うと、SYSTEM_USER を持つユーザ(システムユーザ)の権限を変更したり、ユーザを削除するには、SYSTEM_USER 権限を持っている必要があります。 SUPER 等の静的権限では、システムユーザを変更できません。

スマートスタイルさんの記事がわかりやすいです。

https://blog.s-style.co.jp/2020/06/6097/

システムユーザを作成する。

mysql> CREATE USER 'system_user'@'localhost';
Query OK, 0 rows affected (0.09 sec)

mysql> GRANT SYSTEM_USER ON *.* TO  'system_user'@'localhost';
Query OK, 0 rows affected, 1 warning (0.04 sec)
-- 自分 (root) から SYSTEM_USER を 剥奪
mysql> REVOKE SYSTEM_USER ON *.* FROM 'root'@'localhost';
Query OK, 0 rows affected, 1 warning (0.01 sec)

システムユーザに権限を付与することも、削除することもできなくなりました。

mysql> GRANT SELECT ON *.* TO 'system_user'@'localhost';
ERROR 1227 (42000): Access denied; you need (at least one of) the SYSTEM_USER privilege(s) for this operation

mysql> DROP USER 'system_user'@'localhost';
ERROR 1227 (42000): Access denied; you need (at least one of) the SYSTEM_USER privilege(s) for this operation

SYSTEM_USER 権限を復活(付与)する

SYSTEM_USER 権限の付与は SUPER 権限を持っていれば可能です。

mysql> GRANT SYSTEM_USER  ON *.* TO 'root'@'localhost';
Query OK, 0 rows affected, 1 warning (0.01 sec)

まとめ

  • SYSTEM_USER 権限を REVOKE してしまうと、例え SUPER 権限をもってても、システムユーザの変更はできない
  • SUPER権限を持つユーザを持っていれば、SYSTEM_USER権限 を付与することは可能

「マネージドサービスを実装するために、追加された仕組みなんだろうなぁ」と思いました