Dirty Read を Go の sql パッケージを使って検証する

Dirty Read の概念を学んだので、動作を試すのに Go の sql パッケージを使って検証スクリプトを書いてみる。

こういうのは手を動かしてみると記憶に残る。

検証はローカルで行う。 DBMSMySQL を使う。

compose.yaml はこちら。

services:
  # MySQL
  mydb:
    image: mysql:8.0
    platform: linux/x86_64
    container_name: mydb
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: mydb
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      TZ: 'Asia/Tokyo'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --general_log=1
    volumes:
      - ./docker/db/data:/var/lib/mysql
    ports:
      - 3306:3306

go.mod はこちら。

module example

go 1.20

require github.com/go-sql-driver/mysql v1.7.1

使用するテーブルはこちら。 サンプルデータも先に入れておく。

DROP TABLE IF EXISTS "tbl";

CREATE TABLE tbl (
     id MEDIUMINT NOT NULL AUTO_INCREMENT,
     name CHAR(30) NOT NULL,
     counter TINYINT NOT NULL,
     PRIMARY KEY (id)
);

INSERT INTO tbl (name, counter)
VALUES ('データ1', 10);

プログラムを書いていく。

DBをOpenしてコネクションを生成する。

トランザクション分離レベルをセッション単位でセットするために、コネクションを明示的に生成する。

以降のコードは conn を Dirty Read を発生させるコネクション、 conn2 を書き込みを実行するコネクションとする。

(エラーハンドリングはすべて panic で済ませている。こちらは、「書き殴りのコードなので落ちた箇所さえわかれば良い」という考えの適当なハンドリングである)

ctx := context.Background()

db, err := sql.Open("mysql", (&mysql.Config{
        User:      "user",
        Passwd:    "password",
        Net:       "tcp",
        Addr:      "localhost:3306",
        DBName:    "mydb",
        ParseTime: true,
    }).FormatDSN())
if err != nil {
    panic(err)
}

conn, err := db.Conn(ctx)
if err != nil {
    panic(err)
}

conn2, err := db.Conn(ctx)
if err != nil {
    panic(err)
}

Dirty Read を発生させたいセッションのみ READ UNCOMMITTED にしておく。

_, err = conn.QueryContext(ctx, "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")
if err != nil {
    panic(err)
}

それぞれのセッションでトランザクションを開始する。

tx, err := conn.BeginTx(ctx, nil)
if err != nil {
    panic(err)
}
                                                     
tx2, err := conn2.BeginTx(ctx, nil)
if err != nil {
    panic(err)
}

書き込み用のトランザクションnumber カラムを 2 に更新する。

_, err = tx2.Query("UPDATE tbl SET counter = 2 WHERE id = 1")
if err != nil {
    panic(err)
}

書き込み対象となったレコードの number カラムを参照する。Dirty Read となり 2 が入ってくる。

var counter int
err = tx.QueryRow("SELECT counter FROM tbl WHERE id = 1").Scan(&counter)
if err != nil {
    panic(err)
}
fmt.Println("counter: ", counter)

実行後にデータを初期状態に直すのが面倒なのでロールバックしておく。

tx.Rollback()
tx2.Rollback()

実行していく。

docker compose up -d
go run main.go
counter:  2

想定通り 2 が参照できている。

業務ではトランザクション分離レベルをいじったことは一度もない。

Dirty Read が発生するのは READ UNCOMMITTED のみなので、Dirty Read に遭遇したこともなければ考慮したこともなかった。

が、RDB を触るプログラマーとして頭に入れておくべきだよなーと思って検証してみた。Non-repeatable Read、Phantom Read の検証も同じように行って記事にする予定。