読者です 読者をやめる 読者になる 読者になる

BizStationブログ

ビズステーション株式会社の公式ブログです。

MySQL/MariaDB UPDATEとDELETEの内部ロジック

MySQL MariaDB Transactd

前回のブログ「MySQL/MariaDBとTransactdのInnoDBロック制御詳細」で、「ロックなし読み取りと更新の混在に注意」と書きましたが、「更新前の値を条件で指定したUPDATEやDELETEは問題ないと思うが心配だ」という質問をいただきました。そこで今回は、UPDATEとDELETE文のInnoDBロックについて説明したいと思います。とは言っても、ロックの内容については既に説明済なので、更新処理の内部ロジックについて説明します。それが今回の質問の答えでもあります。

更新前の値をWHERE句で特定した更新

更新前の値をWHERE句で特定した更新の例としては、statusを1から2に変更して状態遷移させるような更新があります。たとえば以下のようなSQL文です。
UPDATE user SET status = 2 WHERE id = 1 and status = 1;
ポイントは、WHEREの条件に status = 1を指定している点です。レコードにバージョン番号フィールドを設けてそれを指定する場合も同様です。*1

UPDATE文内のWHERE id = 1 and status = 1の読み取りは、ロックされたものでしょうか?それとも、Consistent nonlocking readなのでしょうか?

UPDATEとDELETEの内部ロジック

UPDATEとDELETE文では、SELECT句によって更新する対象のレコード(SELECT句が無い場合はすべてのレコード)をフィルタリングします。
例えばすべてのレコードを対象にid >= 5 and id <= 10といった条件でフィルタリングをしたとき、MySQL内部のHandlerインタフェースの疑似コードは以下のようになります。(このサンプルはわかりやすくするため、先頭から最後まで検索しますが、実際はインデックスを使って必要なレコードを可能な限り絞って読み取ります。)

#define FIELD_INDEX_ID 0
#define KEY_ID         0

//テーブルオープン
TABLE_LIST tables;
tables.init_one_table("databaseName",strlen("databaseName"),
                      "tableName",strlen("tableName"), TL_READ);
open_table(thd, tables);
TABLE* table = tables.table;
Handler* file = table->file;
//インテンショロック IXの明示
table.reginfo.lock_type = TL_WRITE; // <-- IX
thd->lock = mysql_lock_tables(thd, &table, 1, 0);

//レコード処理
int keyNum = KEY_ID;
Field* field = *(table.field + FIELD_INDEX_ID);
file->ha_index_init(keyNum , true);
int stat = file->ha_index_first(); // <-- LOCK(X)
while (stat == STATUS_SUCCESS)
{
   if ((field->val_int() >= 5) && (field->val_int() <= 10))
   {   
      // UPDATE record[0]に新しいフィールド値をセットする
      file->ha_update_row(table->record[1],table->record[0]);
   }else
     file->unlock_row();
   file->ha_index_next();  //<-- LOCK(X)
}

//コミットとロック解放
trans_commit_stmt(thd);    
mysql_unlock_tables(thd, thd->lock);

//テーブルクローズ
close_thread_tables(thd);

エラー処理などはだいぶ省いていますがおおよそこのような感じになります。
file->ha_index_first()file->ha_index_next()の部分がレコードを読み取っている部分です。
このコードのロックに関するポイントをまとめます。

  • 更新処理のインテンションロックは無条件に最初にIXが指定される。すなわち、その後に行われる対象レコードを探す読み取りはロックが取得される。
  • (InnoDBの場合)インデックス順に、順次ロック読み取りをしてそれが対象レコードなら、ha_update_row()によって更新が行われる。(削除ならha_delete_row())
  • ロックのタイプは、分離レベルに応じたものになる。
  • 更新対象外のレコードはアンロックが試みられる。(分離レベルによってInnoDBは無視する)

もう、お解かりだと思います。指定した条件の読み取りはロック付き読み取りで、更新時にも間違いなくその値です。更新前の値をWHERE句で特定した更新は、事前にロックもトランザクションの開始も不要です。

自動トランザクション複数行の更新を行った際に他のクライアントとの競合によってロックできなかった場合などは、この文全体が失敗します。部分的に成功してしまう心配も無用です。
ただ、自動トランザクションの場合、その直後に他のクライアントによって変更される可能性があります。続けて何かをする場合はトランザクション内で行いましょう。

Transactdの更新コード

上記の処理内容をTransactdのコードで表現してみました。手続きの流れは上記のHandlerとそっくりです。SQLでしかデータベースを扱ったことがない方も多いと思いますが、実はISAMデータベースのアクセスメソッドはどれもこのようなものです。

#define FIELD_INDEX_ID 0
#define KEY_ID         0

if (db->open(uri))
{
  table* tb = db->openTable(_T("user"));
  db->beginTrn();
  tb->setKeyNum(KEY_ID);
  tb->seekFirst();
  while(tb->stat() ==0)
  {
    if ((tb->getFVint(FIELD_INDEX_ID) >= 5) && 
     (tb->getFVint(FIELD_INDEX_ID) <= 10))
    {
      tb->setFV("status", 2);
      tb->update();
    }
    tb->seekNext();
  }
  db->endTrn();
  tb->release();
  db->close();
}

まとめ

「更新前の値をWHERE句で特定した更新」は条件の読み取りに排他ロックXが使われ、事前にトランザクションを開始しなくても安全に更新できます。ただ、続けて何かをする場合は、トランザクション内で行いましょう。

フィールドにバージョン番号をつけてそれを指定して更新する手法は「楽観的ロック」と呼ばれたりしますが、これもロックが無いわけではなく、InnoDBはレコードをロックしてから更新しています。SELECT ... FOR UPDATEとの違いを大雑把に言うと、1文で処理するか2文で処理するかの違いです。2文で処理するにはBEGINでトランザクションを開始する必要があるので少し手間がかかります。ですが、バージョンフィールドのような特別なフィールドは不要です。

*1:余談ですが、もし事前にトランザクション内で SELECT * from user WHERE id = 1 and status = 1 FOR UPDATE として読み取っていてidがユニークなら、UPDATE文に status = 1 が不要なのは以前の説明で理解していただけると思います。