はじめに
日々の業務でトランザクションを実装したり、新人の頃に基本情報技術者試験を学習していたものの、ACID特性につい理解不足の箇所があったため、改めて学び直そうと思い、記事にしました。
本題
原子性(Atomic)
トランザクション内で実施される処理を全て成功もしくは全て失敗にします。
例えば、Aさんの銀行口座からBさんの口座に100万円送金する処理について、Aさんの口座から100万円減らす処理とBさんの口座に100万円増やす処理の2つを行い、両方の処理を成功もしくは失敗にすることです。Aさんの口座から100万円だけ減らして、Bさんの口座に100万増やす処理をしないという中途半端な状態では終わらないということです。
上記の処理を実現するために、SQLのCOMMITとROLLBACKで実現します。
私はRailsを使用しているため、Railsの場合では以下のような処理になります。
ActiveRecord::Base.transaction do
Tom.send!(1000000)
Taro.receive!(1000000)
end
どちらかの処理が失敗した場合、例外が発生することによって、ROLLBACKされます。その結果、送金および受け取りもなかったことになります。
一貫性(Consistency)
トランザクションの処理結果、データの整合性を担保するために整合性制約を使用して、実現します。例えば、usersテーブルとbooksテーブルが存在している際、1対多の関係で、user_idが999という存在しないidをbooksテーブルに格納すると外部キーエラーが発生させることです。
class User < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :user
end
ActiveRecord::Base.transaction do
Book.create!(user_id: 999, title: "hogehoge")
end
独立性(Isolation)
同時タイミングで複数のトランザクションが並列に実行されるのではなく、直列に実行されることでデータの整合性を保証します。そのために、ロックやシリアライザブルを使用します。
例えば、Aさんの銀行口座からCさんの銀行口座へ100万円送金するタイミングでBさんの銀行口座からCさんの銀行口座へ100万円送金します。その場合、Cさんの口座には本来、AさんとBさんから送金された200万円が口座になければなりません。しかし、同時に送金したことによって、Cさんの口座にBさんから送金された100万円しかないという事態を防ぐためにロックやシリアライズを使用します。
まずはロックについて、説明します。ロックには悲観ロックと楽観ロックがございます。
悲観ロック
あるトランザクションがロックをかけると他のトランザクションはテーブルを更新できない状態になります。他のトランザクションはあるトランザクションが更新を終えるまで待ちの状態になります。Railsで行ロックをかけるには以下のようにlockを使用します。
ActiveRecord::Base.transaction do
Checkout.lock.last
end
生のSQLではSELECT〜FOR UPDATEが発行されることによって、他のトランザクションがテーブルのレコードに更新をかけられないようにしております。
TRANSACTION (0.8ms) BEGIN
Checkout Load (20.3ms) SELECT "checkouts".* FROM "checkouts" ORDER BY "checkouts"."id" DESC LIMIT $1 FOR UPDATE [["LIMIT", 1]]
TRANSACTION (7.7ms) COMMIT
補足ですが、行ロック以外にもテーブルロックもございます。テーブルロックはテーブルごとロックをかけるため、ロックの管理は容易ですが、一方でロックが解除されるまでの待ち時間の発生頻度が増えてしまい、パフォーマンスが行ロックよりも遅くなってしまいます。
行ロックはテーブルロックに比べて、ロックの管理が複雑ですが、テーブルロックよりもロックする範囲がテーブルの行だけのため、テーブルロックよりも待ち時間の発生頻度が少なくり、パフォーマンスがテーブルロックよりも早い可能性が高いです。
楽観ロック
複数のトランザクションが同時タイミングで参照、更新まで実施可能です。テーブルのカラムにバージョン情報を持たせて、そのバージョンが参照時点のものと異なる場合、更新時に例外が発生して、更新ができないようにします。悲観ロックと異なり、更新権限を取得されないため、複数のトランザクションも同時タイミングで更新できるものの、ロールバックおよび例外発生の可能性があるため、その分データを更新するのに手間が発生します。
Railsでは以下のようにlock_versionというカラムを実装します。
class CreateTestUsers < ActiveRecord::Migration[7.0]
def change
create_table :test_users do |t|
t.string :name
t.integer :lock_version
t.timestamps
end
end
end
以下のように同じタイミングで同じレコードを更新までは行えますが、後に更新処理では読み取り時のバージョン情報と更新時のバージョン情報が異なるため、ActiveRecord::StaleObjectErrorという例外が発生しております。
20] pry(main)> user1 = TestUser.last
TestUser Load (1.1ms) SELECT "test_users".* FROM "test_users" ORDER BY "test_users"."id" DESC LIMIT $1 [["LIMIT", 1]]
=> #<TestUser:0x00007fc86d6d4600 id: 1, name: "太郎", lock_version: 0, created_at: Tue, 20 May 2025 09:38:12.786725000 JST +09:00, updated_at: Tue, 20 May 2025 09:38:12.786725000 JST +09:00>
[21] pry(main)>
[22] pry(main)> user2 = TestUser.last
TestUser Load (0.8ms) SELECT "test_users".* FROM "test_users" ORDER BY "test_users"."id" DESC LIMIT $1 [["LIMIT", 1]]
=> #<TestUser:0x00007fc86d6d17c0 id: 1, name: "太郎", lock_version: 0, created_at: Tue, 20 May 2025 09:38:12.786725000 JST +09:00, updated_at: Tue, 20 May 2025 09:38:12.786725000 JST +09:00>
[23] pry(main)>
[24] pry(main)>
[25] pry(main)> user1.name = "花子"
=> "花子"
[26] pry(main)>
[27] pry(main)> user2.name = "健太"
=> "健太"
[28] pry(main)>
[29] pry(main)> user1.save!
TRANSACTION (0.6ms) BEGIN
TestUser Update (1.4ms) UPDATE "test_users" SET "name" = $1, "updated_at" = $2, "lock_version" = $3 WHERE "test_users"."id" = $4 AND "test_users"."lock_version" = $5 [["name", "花子"], ["updated_at", "2025-05-20 00:39:51.359863"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
TRANSACTION (2.4ms) COMMIT
=> true
[30] pry(main)>
[31] pry(main)> user2.save!
TRANSACTION (0.8ms) BEGIN
TestUser Update (1.0ms) UPDATE "test_users" SET "name" = $1, "updated_at" = $2, "lock_version" = $3 WHERE "test_users"."id" = $4 AND "test_users"."lock_version" = $5 [["name", "健太"], ["updated_at", "2025-05-20 00:40:05.285157"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
TRANSACTION (0.8ms) ROLLBACK
ActiveRecord::StaleObjectError: Attempted to update a stale object: TestUser.
from /usr/local/bundle/gems/activerecord-7.0.6/lib/active_record/locking/optimistic.rb:112:in `_update_row'
[32] pry(main)>
シリアライザブルの分離性
以下はANSIによって定義されたシリアライザブルの分離性です。
ダーティリード | 曖昧な読み取り | ファントム | |
---|---|---|---|
リードアンコミッテッド | ◯ | ◯ | ◯ |
リードコミテッド | × | ◯ | ◯ |
リピータブルリード | × | × | ◯ |
シリアライザブル | × | × | × |
「ダーティリード」とは例えば、トランザクションでホテルの空き部屋数が10個ある状態を9個にAさんが更新した際、コミット前に別トランザクションであるBさんが部屋数を参照するとコミット前の状態である9個が見えてしまう状態のことです。
「曖昧な読み取り」とは例えば、トランザクションでホテルの空き部屋数が1度目に参照した際は10個であるものの、他のトランザクションが10個から9個に変更してコミットしたことによって、2度目に参照した際、9個に変わってしまうことです。1度目と2度目の部屋数の参照が異なる値になる事です。
「ファントム」とは例えば、トランザクションAで一度目に参照した際は9レコードあるテーブルが2度目に参照した際、レコード数が10になったりする事象です。1度目と2度目の参照の間に別のトランザクションBがレコードを追加したり、削除したりすることによって、トランザクションAに影響が出ております。
railsでシリアライザブルの分離性を指定する場合は以下のように指定します。DBMSによって分離性のデフォルトの挙動が異なりますが、MySQLだとリピータブルリードです。
ActiveRecord::Base.transaction(isolation: :serializable) do
end
持続性(Durability)
一度COMMITしたデータはOSやデータベースサーバの障害が発生した場合でも永続的にデータが失われません。実現方法としては多くのデータベースではトランザクションの処理内容をハードディスクとしてログに記録されます。そして、障害が発生した際にそのログを使用することでデータの復旧を行います。