開発でのロックの重要性とORMのロック実現例|楽観的ロックの利用

前回記事の「開発でのロックの重要性とORMでのロックの実現例 |楽観的ロックの紹介」では、データの不整合が発生する状態とトランザクションの分離レベルについて詳細を紹介し、続いて楽観的ロックと悲観的ロックについて紹介しました。

今回は楽観的ロックを利用する方法についてフレームワークO/R マッパーの具体例を用いて紹介していきます。

楽観的ロックの実現例

Ruby on Rails の場合

Ruby on Rails の場合、O/R マッパーとして ActiveRecord が使われています。
(ActiveRecord とは O/R マッパーの実装パターンの名前であり、同じ名前が使われています。)

次のマイグレーションファイルのようにモデルに lock_version カラムを integer 型で作成します。

# マイグレーションファイル
$ cat db/migrate/20180906171026_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table :users do |t|

      t.string :name, default: ''
      t.timestamps
      t.integer :lock_version
    end
  end
end

すると、ActiveRecord は update メソッドでデータを更新する際にカラムの lock_version をカウントアップして、楽観的ロックを実現します。

更新が競合した場合は、 ActiveRecord::StaleObjectError が発生します。

# update実行時のSQLで例外発生動作
## [スレッド1] ユーザ(user1) を作成
[1] pry(main)> user1 = User.create!(name: 'test')
   (0.0ms)  SAVEPOINT active_record_1
  SQL (0.6ms)  INSERT INTO "users" ("name", "created_at", "updated_at", "lock_version") VALUES (?, ?, ?, ?)  [["name", "test"], ["created_at", "2018-09-06 17:23:40.408881"], ["updated_at", "2018-09-06 17:23:40.408881"], ["lock_version", 0]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> #

## [スレッド2] ユーザ(user1) を参照
[2] pry(main)> user2 = User.find(1)
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #

## [スレッド1] ユーザ(user1) の name を test2 に更新
[3] pry(main)> user1.update(name: 'test2')
   (0.1ms)  SAVEPOINT active_record_1
  SQL (0.2ms)  UPDATE "users" SET "name" = ?, "updated_at" = ?, "lock_version" = ? WHERE "users"."id" = ? AND "users"."lock_version" = ?  [["name", "test2"], ["updated_at", "2018-09-06 17:24:07.820275"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

## [スレッド2] ユーザ(user1) の namae を test3 に更新(★例外発生)
[4] pry(main)> user2.update(name: 'test3')
   (0.1ms)  SAVEPOINT active_record_1
  SQL (0.2ms)  UPDATE "users" SET "name" = ?, "updated_at" = ?, "lock_version" = ? WHERE "users"."id" = ? AND "users"."lock_version" = ?  [["name", "test3"], ["updated_at", "2018-09-06 17:24:15.752651"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
   (0.1ms)  ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::StaleObjectError: Attempted to update a stale object: User.
from /usr/local/src/rails-sample/vendor/bundle/ruby/2.5.0/gems/activerecord-5.1.6/lib/active_record/locking/optimistic.rb:95:in `_update_row'

Grails の場合

Grails では O/R マッパーとして GORM を使います。
GORM は SQL DB へアクセスする際に Hibernate を利用します。

デフォルトでドメインクラスに version プロパティが追加され、楽観的ロックのバージョンとして利用されます。

# Userドメイン
package grails.sample.app

class User {

  String name

  static constraints = {
  }
}

上記のように、version フィールドをドメインクラスに明示的に指定する必要はありません。デフォルトで追加されます。(逆に楽観的ロックを使いたくない場合は version false を mapping に指定する必要があります)

// Userドメインの name を test2 に変更
def user = User.get(1)
println 'name: ' + user.name + ', version: ' + user.version
user.name = 'test2'
user.save(flush: true)
println 'name: ' + user.name + ', version: ' + user.version

// 実行結果
name: test, version: 40
name: test2, version: 41

例外が発生すると org.springframework.dao.OptimisticLockingFailureException エラーが発生します。

SQLAlchemy の場合

SQLAlchemy は O/R マッパーを含む Python 用の SQL Toolkit です。

次のファイルのようにモデルに数値を保存する任意のフィールド(BigInteger 型等) で作成し、 __mapper_args__ の定義内で version_id_col として設定します。

from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String

class User(Base):
  __tablename__ = 'users'

  id = Column('id', Integer, primary_key = True)
  name = Column('name', String(200))
  version_id = Column(BigInteger, nullable=False)

  __mapper_args__ = {
    'version_id_col': version_id
  }

すると、SQLAlchemy はセッションが commit されてデータを更新する際にカラムの version_id をカウントアップして、楽観的ロックを実現します。

更新が競合した場合は、 sqlalchemy.orm.exc.StaleDataError が発生します。

# スレッド1
## モデルファイルと DB Connector の読み込み
>>> from setting import session
>>> from user import *

## User の name を test として作成
>>> user = User()
>>> user.name = 'test'
>>> session.add(user)
>>> session.commit()

## User の name を test6 へ変更
>>> user.name = 'test6'
>>> session.commit()
2018-09-11 03:34:45,280 INFO sqlalchemy.engine.base.Engine UPDATE users SET name=%s, version_id=%s WHERE users.id = %s AND users.version_id = %s
2018-09-11 03:34:45,280 INFO sqlalchemy.engine.base.Engine ('test6', 4, 1, 3)
2018-09-11 03:34:45,281 INFO sqlalchemy.engine.base.Engine COMMIT
>>> 

# スレッド2
## モデルファイルと DB Connector の読み込み
>>> from setting import session
>>> from user import *

## User を取得
>>> user = session.query(User).filter(User.id==1).first()
2018-09-11 03:33:34,195 INFO sqlalchemy.engine.base.Engine SHOW VARIABLES LIKE 'sql_mode'
2018-09-11 03:33:34,196 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine SELECT DATABASE()
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine show collation where `Charset` = 'utf8mb4' and `Collation` = 'utf8mb4_bin'
2018-09-11 03:33:34,198 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,200 INFO sqlalchemy.engine.base.Engine SELECT CAST('test plain returns' AS CHAR(60)) AS anon_1
2018-09-11 03:33:34,200 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,201 INFO sqlalchemy.engine.base.Engine SELECT CAST('test unicode returns' AS CHAR(60)) AS anon_1
2018-09-11 03:33:34,201 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,202 INFO sqlalchemy.engine.base.Engine SELECT CAST('test collated returns' AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_bin AS anon_1
2018-09-11 03:33:34,202 INFO sqlalchemy.engine.base.Engine ()
2018-09-11 03:33:34,202 INFO sqlalchemy.engine.base.Engine BEGIN (implicit)
2018-09-11 03:33:34,203 INFO sqlalchemy.engine.base.Engine SELECT users.id AS users_id, users.name AS users_name, users.version_id AS users_version_id 
FROM users 
WHERE users.id = %s 
 LIMIT %s
2018-09-11 03:33:34,203 INFO sqlalchemy.engine.base.Engine (1, 1)

## User の name を test5 に変更する(★例外発生)
>>> user.name = 'test5'
>>> session.add(user)
>>> session.commit()
2018-09-11 03:35:00,504 INFO sqlalchemy.engine.base.Engine UPDATE users SET name=%s, version_id=%s WHERE users.id = %s AND users.version_id = %s
2018-09-11 03:35:00,504 INFO sqlalchemy.engine.base.Engine ('test5', 4, 1, 3)
2018-09-11 03:35:00,504 INFO sqlalchemy.engine.base.Engine ROLLBACK
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 943, in commit
    self.transaction.commit()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 467, in commit
    self._prepare_impl()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 447, in _prepare_impl
    self.session.flush()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 2254, in flush
    self._flush(objects)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 2380, in _flush
    transaction.rollback(_capture_exception=True)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/util/langhelpers.py", line 66, in __exit__
    compat.reraise(exc_type, exc_value, exc_tb)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/util/compat.py", line 249, in reraise
    raise value
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/session.py", line 2344, in _flush
    flush_context.execute()
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/unitofwork.py", line 391, in execute
    rec.execute(self)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/unitofwork.py", line 556, in execute
    uow
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/persistence.py", line 177, in save_obj
    mapper, table, update)
  File "/PATH/lib/python3.5/site-packages/sqlalchemy/orm/persistence.py", line 791, in _emit_update_statements
    (table.description, len(records), rows))
sqlalchemy.orm.exc.StaleDataError: UPDATE statement on table 'users' expected to update 1 row(s); 0 were matched.

O/R マッパーにおける楽観的ロックの実装有無

ここまでで、幾つかの O/R マッパーを使って楽観的ロックを実装するための具体例を見てきました。

他の O/R マッパーを含めて、楽観的ロックの実装があるかどうか以下に纏めます。

ORM名 楽観的ロックの実装有無 発生する例外 参考情報
GORM org.springframework.dao.OptimisticLockingFailureException GORM for Hibernate > Pessimistic and Optimistic Locking
ActiveRecord ActiveRecord::StaleObjectError Ruby on Rails API > ActiveRecord::Locking::Optimistic
SQLAlchemy sqlalchemy.orm.exc.StaleDataError SQLAlchemy > Class Mapping API
Hibernate org.hibernate.StaleObjectStateException Hibernate/楽観的ロックを実装する
JPA javax.persistence.OptimisticLockException Java7 API - Class OptimisticLockException
Eclipse Link(Toplink Essentials) javax.persistence.OptimisticLockException ※Eclipse Link は JPA の実装である。stack overflow - what-is-the-difference-between-toplink-essentials-eclipselink
MyBATIS(旧iBATIS) × - MyBatisで行ロック(悲観ロック)を行うには?
Django の ORM × -
Sequelize OptimisticLockError Sequelize > Model definition > Optimistic Locking

NoSQL データベースにおける楽観的ロック実装について

トランザクション処理が存在しない NoSQL では楽観的ロックが多く使われている。
(Memcached の CAS(Check and Set) が楽観的ロックを指す)
※参考: Slide Share - db-tech-showcase-mongodbP.44-50 付近

O/R マッパーにおける楽観的ロックの実装有無

ORM名 楽観的ロックの実装有無 発生する例外 参考情報
Mongoose VersionError mongoose > versionKey

最後に

今回はいくつか具体的な OR マッパーを例にして楽観的ロックを利用する方法を紹介し、OR マッパーにおいて楽観的ロックを実装しているかどうかをまとめて紹介しました。

3回に渡って紹介してきた楽観的ロックに関連する記事は今回で最終回となります。

ソフトウェアを扱う上で重要なロックについて、皆さんが理解する・復習するための一助となりましたら幸いです。