LoginSignup
0

More than 1 year has passed since last update.

posted at

updated at

Node.js+Sequelizeで楽観的ロックを使って動作を確認する

PONOS Advent Calendar 2020の8日目の記事です。

昨日は@e73ryoさんでした。

はじめに

前回に続いてまたSequelizeの話です。

データの整合性をたもつための排他制御として代表的なものとして悲観的ロックと楽観的ロックというアプローチがあります。
それ自体については沢山記事がありますのでここでは言及致しませんが、基本的に自身のデータしか操作しないようなケースにおいては、操作が競合する可能性が低いため、実現が簡単で不具合の生みにくい楽観的ロックを採用するケースが多いかなと思います。

ということでSequelizeで楽観的ロックを利用する方法をメモ的に書いておきたいと思います。
(といってもめっちゃ簡単な話なんですが)

この記事の対象者

Node.jsとSequelizeの基本的な知識があることを前提とします。
この記事ではこれらの細かい部分については言及しません。

検証環境

  • Node.js 12.19.0
  • Sequelize 6.3.5
  • Sequelize-CLI 6.2.0
  • MySQL 5.7.25

方法

まずは雛形のModelを作成する

今回もSequelize-CLIを使ってModelファイルとマイグレーションファイルを作成します。
ここではuserというテーブルにnameとageフィールドを持たせます。

npx sequelize model:generate --name user --underscored --attributes name:string,age:integer

※ --underscoredを指定しているのはテーブル定義上は名称をスネークケース、コード上はキャメルメースにしたいだけで、本質的には今回の件と関係ありません。
※ 生成されるファイル内容については前回と同様なので省略します。

Modelの設定を変更する

Sequelizeに楽観的ロックの動作を行わせるには、Modelにversion: trueを設定するだけです。

user.jsを編集する

'use strict';
const {
  Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class user extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  };
  user.init({
    name: DataTypes.STRING,
    age: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'sample',
    underscored: true,
    version: true,
  });
  return user;
};

マイグレーションファイルにversionを追加する

テーブルにversion列が必要なので、定義を追加します。
ちなみにここではversionにデフォルト値を設定していませんが、ORマッパーはINSERT時にも自動的に値を設定するので動作します。

xxxxxxxxxxx-create-user.jsを編集する

'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      name: {
        type: Sequelize.STRING
      },
      age: {
        type: Sequelize.INTEGER
      },
      created_at: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updated_at: {
        allowNull: false,
        type: Sequelize.DATE
      },
      version: {
        allowNull: false,
        type: Sequelize.INTEGER
      }
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('users');
  }
};

動作を確認する

うまく動作するのかを検証します。
※ 結論からいうとうまく動作するので、Sequelizeでの楽観的ロックの設定だけ知りたい方は、「以上、終了」という感じです。

マイグレーションを実行してテーブルを準備します。

npx sequelize db:migrate

正常系

まずはインサートしてみる

await db.user.create({
    name: "sample",
    age: 10
});

下記のデータが登録されました。

id name age created_at updated_at version
1 sample 10 実行日時 実行日時 0

次はこれをアップデートしてみます。

const user = await db.user.findOne({
    where: {
        id: 1
    }
});
user.age += 1;
await user.save();

下記のようにデータが更新されました。
versionが自動的にインクリメントされており、どうやらうまく動作しているようです。

id name age created_at updated_at version
1 sample 11 以前の日時 今回の実行日時 1

異常系

それでは上記の手順に続いて、本当に楽観ロックが機能しているのかを確認します。

同時にアクセスがあり、トランザクションを開始してデータを取得して更新するケースを想定します。

この時二つのトランザクションA、トランザクションBは同じ状態のデータを取得することになります。
その後、トランザクションAのほうはage+1を行いコミットします。
トランザクションBのほうはage+10を行ってコミットするという想定でコードを書いてみます。

※ トランザクション分離レベルはREPEATABLE READで行っています。

// 同時にトランザクションを開始する。
let transactionA = await db.sequelize.transaction();
let transactionB = await db.sequelize.transaction();

try {
    // まずトランザクションAでデータを取得
    const userA = await db.user.findOne({
        where: {
            id: 1
        }
    }, { transaction: transactionA });
    console.log(`A age: ${userA.age} version: ${userA.version}`);

    // 次にトランザクションBでデータを取得
    const userB = await db.user.findOne({
        where: {
            id: 1
        }
    }, { transaction: transactionB });
    console.log(`B age: ${userB.age} version: ${userB.version}`);

    // トランザクションAがage+1してコミット
    userA.age += 1;
    console.log("A update");
    await userA.save({ transaction: transactionA });
    console.log("A commit");
    transactionA.commit();
    transactionA = null;

    // トランザクションBがage+10してコミット
    userB.age += 10;
    console.log("B update");
    await userB.save({ transaction: transactionB });
    console.log("B commit");
    transactionB.commit();
    transactionB = null;
} catch (error) {
    console.log(error);
    if (transactionA) {
        console.log("rollback A");
        transactionA.rollback();
    }
    if (transactionB) {
        console.log("rollback B");
        transactionB.rollback();
    }
}

これを実行すると下記の順でログが出力されました。

A age: 11 version: 1
B age: 11 version: 1
A update
A commit
B update
OptimisticLockError [SequelizeOptimisticLockError]: Attempting to update a stale model instance: user
(省略)
rollback B

このログから分かる通り、AもB同じデータを参照しており、Aはコミットに成功しましたが、Bはupdate実行時にOptimisticLockError例外が発生し、ロールバックされたようです。

結果のデータベースは下記のようになっています。

id name age created_at updated_at version
1 sample 12 以前の日時 今回の実行日時 2

Aが行った操作であるage+1が反映されていて、versionは1になっています。

まとめ

ということで動作検証も行ったので記事が長くなりましたが、Sequelizeで楽観ロックを導入するのはとても簡単でした。

明日は@kerimekaさんです!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
0