前回記事の「開発でのロックの重要性と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回に渡って紹介してきた楽観的ロックに関連する記事は今回で最終回となります。
ソフトウェアを扱う上で重要なロックについて、皆さんが理解する・復習するための一助となりましたら幸いです。