データベースにデータを格納する処理の利点は、ファイルにデータを格納するのと違い、データを安全に処理する仕組みが備わっているという点にあります。レ コードを挿入・変更・削除するときに、別プロセスからの処理に干渉されないように、データの整合性を保つます。処理中に障害が発生したときに、障害発生前 に安全に戻せるような仕組みなどが、これにあたります。
データベースのACID特性
データベースによる性能評価の基準として以下のようなものがあります。
Atomicity 原子性
データベースの処理がすべて実行されるか、障害により、すべてが実行直前の状態に戻るかになることを保証します。
Consistency 一貫性
データの更新によって、どこからアクセスしても、同じデータが得られることを保証する。
Isolation 分離性
複数のデータベース処理が干渉しあうことが無いことを保証する。
Durability 持続性
データベース処理実行後はその後の障害があっても、損なわれることは無い。
同時実行制御
複数のプロセス(スレッド)が単一のデータベースにデータを格納する為には、sql文が同時に投入されたときに制御する仕組みが必要となってきま す。格納処理は最終的には、一つずつ順番に処理しなければ、正しく格納される保証が無いからです。また、格納処理にエラーが出てしまえば、同時に処理され るデータの整合性が失われてしまう可能性があります。sqlite を含めRDMS といわれるデータベースエンジンには、これを制御する同時実行制御がそなわっております。このことは、上記の分離性の保証にかかわっております。
そして、この仕組みを実現させるために、トランザクション処理が備わっております。
同時実行によるデータの一貫性や分離性が損なわれる問題
同時実行制御するにあたって、以下のようなことを留意する必要があります。
- コミットされていないデータを別のトランザクションから参照できてしまう。DirtyRead
- 別のトランザクションによる書き込みで, 更新したデータが上書きされてしまう。LostUpdate
- トランザクション内で別のトランザクションによるデータ更新が反映されてしまい, 同じレコードでも参照するたびに結果が変わる。FuzzyRead
- 同じトランザクション内でも, データの参照結果が増減する. 別のトランザクションによるデータ更新 or 削除が参照できることが原因で発生する。PhantomRead
トランザクション処理について
トランザクションとは、一連のデータベース操作を1つのまとまりの処理として実行できる機能です。一つのまとまりとしての処理は以下のような構成になっております。
- 外部キー制約が有効になっている場合は、PRAGMA foreign_keys=OFF を使用して無効にします。
- BEGIN TRANSACTION or BEGIN トランザクション開始
- ジャーナルファイルを生成(または生成されている場合もあり。)し、コミットが済んでいるデータ情報を書き込む(先行書き込み WAL)。
- 書き込みステートメント発行 (CREATE、DELETE、DROP、INSERT、UPDATE など)。
- ROLLBACK または COMMIT sql文を発行する。
- ROLLBACK の場合は、ジャーナルファイルを使って、トランザクション開始前の状態に戻します。COMMIT の場合は、ジャーナルファイルモードにしたがって、ファイルを書き換えまたは削除します。
- 外部キー制約が有効になっている場合は、PRAGMA foreign_keys=ONに戻します。
ロックによる同時実行制御の実現
ロックとは、あるリソース、ここではトランザクションに対して、鍵をかけ、他のトランザクションが実行できない(待機させる)ようにすることです。sqlite では以下のようなロック状態をがあります。
UNLOCKED ロック解除
ロックを保持していない状態です。どのプロセスも書き込みまたは読み込みしていないデータベースの初期状態です。
SHARED 共有
データを読み込む場合に取得される状態。この状態から書きこみステートメントを発行するとRESERVEDに移行する。複数のプロセス(スレッド)を保持できる。他のプロセスからの読み込みに対してはオープンであるため、読み込みを許可するが書き込みは許可しない。
RESERVED 予約済み
プロセスが将来書き込みを行う予定であるが、書き込みは実行されていない。読み込みは許可されている。但し、SHAREDロックから唯一のプロセス しか受け入れられないので、複数のプロセスから、同時にRESERVEDできない。RESERVED ロックがある間に新しい SHARED ロックを取得できるという点で PENDING とは異なります。
PENDING 保留中
EXCLUSIVEロックを取得して書き込みを実行するために、SHARED状態からの解放をするために待機している状態である。EXCLUSIVE以外のロック状態の取得はできない。常に EXCLUSIVE ロックへの一時的な踏み台にすぎない。
EXCLUSIVE 排他
実際に書き込み処理を実行している状態。唯一の状態なので、他のロック状態の取得はできない。同時実行性を最大化するために、SQLite は EXCLUSIVE ロックが保持される時間を最小限に抑えるように機能します。
トランザクションモード
トランザクション内で、上記のロックを取得するタイミングの違いを以下のモード設定ステートメントで制御できます。
BEGIN DEFERRED
BEGIN のみの場合も同じ。データベースが最初にアクセスされるまでトランザクションが実際に開始されないし、ロック状態も取得されない。最初のread処理でSHAREDロックを取得し、最初のwrite処理でRESERVEDロックを取得する。
BEGIN IMMEDIATE
書き込みステートメントを待たずに、データベース接続が新しい書き込みをすぐに開始するようにします。開始時にRESERVEDロックを取得する。 他のどのdatabase connectionも、DBへのwrite、IMMEDIATE/EXCLUSIVEトランザクションの開始はできなくなる。ただし、readは可能。
BEGIN EXCLUSIVE
開始時にEXCLUSIVEロックを取得する.全てのデータベースコネクションからデータを読み込むこと(read uncomitted” connectionを除く)、書き込むことが出来なくなる。
自動コミット機能
明示的にトランザクションを開始する、しないに関わらず、DBの書き込みステートメント(追加・更新・削除)ではかならずトランザクションが実行されます。
BEGIN TRANSACTIONなしでDBを更新するSQLを発行した場合には、内部で自動的にトランザクションが開始され、SQL実行後にコミットされることになります。
BEGIN TRANSACTIONを明示的に実行した場合には、COMMITもしくはROLLBACKを実行するまでトランザクション状態が継続されます。
但し、以下のsql分では、トランザクションされても、自動コミット機能が自ずと適用されているので、ロールバックできません。
- DROP DATABASE
- DROP TABLE
- DROP
- ALTER TABLE
ロールバックジャーナルファイル
データベースに障害があった場合に正常な状態に戻るために使用されるファイルです。
データベース ファイルから読み取る前に、SQLite は常にデータベース ファイルにホット ジャーナルがあるかどうかを確認します。
ト ランザクションが完了する前にアプリケーションまたはホスト コンピュータがクラッシュした場合、ロールバック ジャーナルまたは先行書き込みログには、メイン データベース ファイルを整合性のある状態に復元するために必要な情報が含まれています。ロールバック ジャーナルまたは先行書き込みログにデータベースの状態を回復するために必要な情報が含まれている場合、それらは「ホット ジャーナル」または「ホット WAL ファイル」と呼ばれます。
ジャーナルモード
ジャーナルファイルには、トランザクション処理ごとに書き込みが行われます。この処理が、膨大な回数になれば、そのコストも無視できないものになります。sqlite の場合は、pragmaコマンドを使用して設定を切り替えることが出来ます。
PRAGMA schema.journal_mode = DELETE | TRUNCATE | PERSIST | MEMORY | WAL | OFF
*schemaはデータベースファイル名(エイリアス)、単一データベース操作の場合は main
デフォルトでは delete が設定されており、トランザクション処理が開始した時に生成され、終了とともに消失してしまう。実行コストと持続性Durabilityを考えて、ファイルを残す、 TRUNCATE |あるいはPERSISに変えることもできる。MEMORYはファイルをメモリー上に保持されます。
適切にトランザクション処理をチューニングする
「同時実行によるデータの一貫性や分離性が損なわれる問題」をすべて解消すれば、データベースのACID特性は満たされることになる。ただし、パ フォーマンスは犠牲になる。処理に著しく時間がかかることになります。この問題は、使用するデータベースの特性を考慮した上で、データの一貫性や分離性を 一部犠牲にして考える必要があるといわれております。
データベースを適切に利用するための要因
- 取り扱うデータ量
- 書き込みトランザクションが頻繁に発生する。書き込み中心である。
- トランザクション処理時間
- ロック取得期間
- ロックするデータの範囲(ロック粒度またはロックレベル)。テーブル全体, 行全体, 特定の列がある。SQLiteではデータベース全体のロック粒度しか選択できない。
パフォーマンスを下げる現象
ロックにより、同時実行制御を実現できるようなりました。しかし、その代償として以下のような現象を考えなければいけないことになっております。
スラッジング
更新ステートメントが頻繁に発効される状況において、排他ロックにより、待ち状態が頻出し、パフォーマンスが著しく低下してしまう。ロック粒度を下げるか、更新データ量を制限する等の対策が必要となってきます。
デッドロック
トランザクション内に複数の更新ステートメントがある場合、他のトランザクションが同時に投入された場合、その中の複数の更新ステートメントが、最 初に投入されたトランザクションの更新ステートメントの排他ロックをお互いに掛け合ってしまい、処理まちが永遠に続いてしまう。このような複数の更新ス テートメント実行を取り合ってしまうような場合は、データベース設計を考える必要があります。
トランザクションモードとデータの一貫性や分離性が損なわれる問題との関係
DEFERRED | IMMEDIATE | EXCLUSIVE | |
DirtyRead | ○ | ○ | ○ |
LostUpdate | × | ○ | ○ |
FuzzyRead | × | ○ | ○ |
PhantomRead | × | × | ○ |
一般的には、更新処理があまりない、参照中心の場合は、一貫性・分離性は緩くし、パフォーマンスを優先するようです。逆に、更新が頻発するような場合、パフォーマンスよりは、データの整合性を重視した方向でチューニングされるべきとしています。
トランザクションをコマンドラインで検証してみる
trans_sample テーブルを作成し、first second 二つのターミナルの間で、トランザクション処理をやりあってみます。
ジャーナルモードの変更
-- first terminal
$ sqlite3 trans_sample.sq3
SQLite version 3.34.0 2020-12-01 16:14:00
Enter ".help" for usage hints.
sqlite> .databases
main: /home/takai/db/trans_sample/trans_sample.sq3 r/w
---- サンプルテーブルを生成する
sqlite> create table trans_sample (id integer primary key, name text, score integer);
---- second terminal
takai@takai-pc ~/db/trans_sample
$ ls -la
合計 20
drwxr-xr-x 1 takai None 0 9月 21 17:35 .
drwxr-xr-x 1 takai None 0 9月 21 17:33 ..
-rwxr-xr-x 1 takai None 8192 9月 21 17:35 trans_sample.sq3 -- データベースファイルが生成されている
---- first terminal トランザクション開始
sqlite> begin transaction; -- begin; でもok
sqlite> insert into trans_sample values(null, 'takai', 10); -- データを挿入 commit前
---- second terminalジャーナルファイル生成確認
takai@takai-pc ~/db/trans_sample
$ ls -la
合計 28
drwxr-xr-x 1 takai None 0 8月 29 17:42 .
drwxr-xr-x 1 takai None 0 8月 29 17:27 ..
-rwxr-xr-x 1 takai None 8192 8月 29 17:34 trans_sample.sq3
-rwxr-xr-x 1 takai None 4616 8月 29 17:42 trans_sample.sq3-journal -- ジャーナルファイル生成される
sqlite> .tables
trans_sample
sqlite> select * from trans_sample;
-- テーブルは作成されているが、データは反映されない。
---- first terminalロールバックしてみる
sqlite> select * from trans_sample;
1|takai|10 -- transaction 実行側では commit 前でも参照は出来る
sqlite> rollback transaction; -- rollback; でも良い
sqlite> select * from trans_sample;
-- データが格納されない前の状態に戻る
---- second terminal
takai@takai-pc ~/db/trans_sample
$ ls -la -- rollback 後のファイルを表示してみる
合計 20
drwxr-xr-x 1 takai None 0 8月 29 17:53 .
drwxr-xr-x 1 takai None 0 8月 29 17:27 ..
-rwxr-xr-x 1 takai None 8192 8月 29 17:34 trans_sample.sq3
-- ジャーナルファイルが消えている
---- first terminalジャーナルモードをpersist に変更する
sqlite> .databases
main: /home/takai/db/trans_sample/trans_sample.sq3 r/w -- main がカレントデータベース名になっている。
sqlite> pragma main.journal_mode; -- ジャーナルモードを調べる
delete -- デフォルトの設定
sqlite> pragma main.journal_mode = persist; -- persist に変更する
persist
---- second terminal
takai@takai-pc ~/db/trans_sample2
$ ls -la
合計 28
drwxr-xr-x 1 takai None 0 8月 31 13:00 .
drwxr-xr-x 1 takai None 0 8月 31 12:53 ..
-rwxr-xr-x 1 takai None 8192 8月 31 12:58 trans_sample.sq3
-rwxr-xr-x 1 takai None 4616 8月 31 13:00 trans_sample.sq3-journal -- ジャーナルファイルが生成されている
---- first terminal
sqlite> begin;
sqlite> insert into trans_sample values(null, 'takai', 10); -- データを挿入
sqlite> rollback; -- 開始時にもどす
---- second terminal
takai@takai-pc ~/db/trans_sample2
$ ls -la
合計 28
drwxr-xr-x 1 takai None 0 8月 31 13:00 .
drwxr-xr-x 1 takai None 0 8月 31 12:53 ..
-rwxr-xr-x 1 takai None 8192 8月 31 12:58 trans_sample.sq3
-rwxr-xr-x 1 takai None 4616 8月 31 13:02 trans_sample.sq3-journal -- ジャーナルファイルは開始時の状態で存在している
---- first terminal
sqlite> begin;
sqlite> insert into trans_sample values(null, 'takai', 10);
sqlite> commit; -- データを挿入しコミットする
---- second terminal
drwxr-xr-x 1 takai None 0 8月 31 13:00 .
drwxr-xr-x 1 takai None 0 8月 31 12:53 ..
-rwxr-xr-x 1 takai None 8192 8月 31 13:08 trans_sample.sq3
-rwxr-xr-x 1 takai None 8720 8月 31 13:08 trans_sample.sq3-journal -- コミットされた分データ量が増えている
ジャーナルファイルはデフォルトでは、delete に設定されているので、トランザクションが終了すると消失してしまいます。persist は終了時にジャーナルファイルのヘッダーの部分を0に上書きすることになり、ファイル削除にかかる処理コストの節約につながるようです。 truncate はファイルを削除するのではなく、0バイトの長さに切り捨てることにより、コスト削減する方式になります。
トランザクションに名前をつける[savepoint]
savepoint transaction-name;
トランザクションに名前をつけ、トランザクションをロールバックするジャーナルファイルを生成
ROLLBACK TRANSACTION TO SAVEPOINT savepoint-name
データベースの状態を、対応する savepoint-name の直後の状態に戻します。
トランザクション自体はキャンセルされない。
RELEASE savepoint-name
savepoint-name を開放する。
---- first terminal
sqlite> select * from trans_sample;
1|takai|10 -- 現在の格納されているデータ
sqlite> pragma main.journal_mode = delete; -- ジャーナルモードをデフォルトに戻す
delete
---- セーブポイントsp1設置
sqlite> savepoint sp1;
sqlite> insert into trans_sample values(null, 'sato', 20);
sqlite> select * from trans_sample;
1|takai|10
2|sato|20 -- release前のデータは参照できる
---- second terminal
takai@takai-pc ~/db/trans_sample3
$ ls -la
合計 28
drwxr-xr-x 1 takai None 0 9月 21 17:55 .
drwxr-xr-x 1 takai None 0 9月 21 17:33 ..
-rwxr-xr-x 1 takai None 8192 9月 21 17:44 trans_sample.sq3
-rwxr-xr-x 1 takai None 4616 9月 21 17:55 trans_sample.sq3-journal -- ジャーナルファイルは生成されている
takai@takai-pc ~/db/trans_sample3
$ sqlite3 trans_sample.sq3
SQLite version 3.34.0 2020-12-01 16:14:00
Enter ".help" for usage hints.
sqlite> select * from trans_sample;
1|takai|10 -- コミットされていないデータは参照されない
---- first terminal sp1開放
sqlite> release sp1; -- sp1 トランザクションを開放する。このケースでは commit; したのと同じ
---- second terminal
sqlite> select * from trans_sample;
1|takai|10
2|sato|20 -- 挿入データがコミットされている
---- first terminal 入れ子ケース
sqlite> begin; -- 外側の無名トランザクションを宣言
sqlite> insert into trans_sample values(null, 'tanaka', 30);
sqlite> savepoint sp1; -- 内側に sp1 トランザクションを宣言
sqlite> insert into trans_sample values(null, 'nakata', 40);
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40 -- 挿入されたデータは内側では参照できる
---- second terminal ジャーナルファイル生成確認
sqlite> .exit
takai@takai-pc ~/db/trans_sample3
$ ls -la
合計 28
drwxr-xr-x 1 takai None 0 9月 21 18:15 .
drwxr-xr-x 1 takai None 0 9月 21 17:33 ..
-rwxr-xr-x 1 takai None 8192 9月 21 18:09 trans_sample.sq3
-rwxr-xr-x 1 takai None 4616 9月 21 18:15 trans_sample.sq3-journal -- ジャーナルファイル生成
takai@takai-pc ~/db/trans_sample3
$ sqlite3 trans_sample.sq3
SQLite version 3.34.0 2020-12-01 16:14:00
Enter ".help" for usage hints.
sqlite> select * from trans_sample;
1|takai|10
2|sato|20 -- commit されていないデータは参照されない
---- first terminal 内側のsp1を開放
sqlite> release sp1; -- sp1トランザクション直前まで開放する
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
---- second terminal
sqlite> select * from trans_sample;
1|takai|10
2|sato|20 -- 外側のトランザクションまで commit されていないのでデータは参照されない
---- first terminal 再びsp1設置し、データを挿入後sp1までロールバックする。
sqlite> savepoint sp1; -- 再び内側トランザクションを設定
sqlite> insert into trans_sample values(null, 'yamada', 50); -- データを挿入
sqlite> rollback to sp1; -- 内側トランザクション直前までロールバックする
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40 -- 直前の sp1 設置前の状態に戻る
---- second terminal
sqlite> select * from trans_sample;
1|takai|10
2|sato|20 -- commit 前の状態を維持
---- first terminal 外側のトランザクションをコミットする
sqlite> commit;
---- second terminal
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40 -- コミット後のデータが参照できる
トランザクションモード
BEGIN DEFERRED or BEGIN 無指定モード
---- first terminal 無指定モード開始
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|4
sqlite> begin; -- <unlocked>
---- second terminal
sqlite> select * from trans_sample where rowid=2; -- read ok
2|sato|20
sqlite> insert into trans_sample values(null, 'yamada', 50); -- write ok
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
---- first terminal
sqlite> select * from trans_sample; -- <shared>
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
---- second terminal
sqlite> select * from trans_sample where rowid=3; -- read ok
3|tanaka|30
sqlite> insert into trans_sample values(null, 'hayasi', 60); -- write no
Error: database is locked
---- first terminal
insert into trans_sample values(null, 'hayasi', 60); -- <reserved>
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
6|hayasi|60
---- second terminal
sqlite> select * from trans_sample; -- read ok but being continue previous state
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
---- second terminalを無指定でトランザクション開始
sqlite> begin; -- <unlocked>
sqlite> select * from trans_sample; -- <shared> -- first second 両者がロックを取得している状態
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
---- first terminal
sqlite> insert into trans_sample values(null, 'watanabe', 70); -- reserved lock を取得しているので write ok
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
6|hayasi|60
7|watanabe|70
---- second terminal
sqlite> insert into trans_sample values(null, 'ishida', 80); -- shared lock を取得しているものの first terminal で commit されていないので、reserved lock に移行できない
Error: database is locked
---- first terminal
sqlite> commit; -- second terminal から同じ資源をロックしあっている。デッドロック
Error: database is locked
---- second terminal
sqlite> commit; -- トランザクションをコミットする
sqlite> select * from trans_sample; -- デッドロック中
Error: database is locked
---- first terminal コミットする
sqlite> commit; -- first second 両者が commit されたのでデッドロックが解除される
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
6|hayasi|60
7|watanabe|70
---- second terminal
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
6|hayasi|60
7|watanabe|70
BEGIN IMMEDIATE モード
---- first terminal IMMEDIATE モード開始
sqlite> begin immediate; -- <reserved>
---- second terminal
sqlite> select * from trans_sample where id=5; -- read ok
5|yamada|50
sqlite> delete from trans_sample where id=5; -- delete (and write statement) no
Error: database is locked
sqlite> begin; -- <shared>
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
5|yamada|50
6|hayasi|60
7|watanabe|70
---- first terminal reservedロック状態でdelete
sqlite> delete from trans_sample where id=5; -- <>
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
6|hayasi|60
7|watanabe|70
---- second terminal
sqlite> delete from trans_sample where id=6; -- write statement no
Error: database is locked
---- first terminal
sqlite> delete from trans_sample where id=6;
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
7|watanabe|70
sqlite> commit; -- second commitされていないので commitできない
Error: database is locked
---- second terminal コミットする
sqlite> commit;
sqlite> select * from trans_sample; -- commit したが、first でロックが解除されていない
Error: database is locked
---- first terminal ロックを解除
sqlite> commit;
-- second terminal
sqlite> select * from trans_sample; -- commit 後のデータが参照できる
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
7|watanabe|70
BEGIN EXCLUSIVE
---- first terminal EXCLUSIVEモード開始
sqlite> begin exclusive; -- <exclusive>
---- second terminal
sqlite> select * from trans_sample; -- read no
Error: database is locked
sqlite> delete from trans_sample where id=4; -- delete (and write and update) no
Error: database is locked
sqlite> begin immediate; -- secondからロックすべてできない
Error: database is locked
sqlite> begin exclusive;
Error: database is locked
sqlite> begin;
sqlite> .tables
Error: database is locked
---- first terminal
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
4|nakata|40
7|watanabe|70
sqlite> delete from trans_sample where id=4;
sqlite> select * from trans_sample;
1|takai|10
2|sato|20
3|tanaka|30
7|watanabe|70
sqlite> commit;
---- second terminal
sqlite> select * from trans_sample; -- read no
Error: database is locked
sqlite> commit;
Error: cannot commit - no transaction is active
---- first terminal コミット
sqlite> commit;
--second terminal
sqlite> select * from trans_sample; -- read ok
1|takai|10
2|sato|20
3|tanaka|30
7|watanabe|70