わかりやすい JPA(12)
楽観的ロックと悲観的ロック

はじめに

JPAでのロックの基本は楽観的ロック(Optimisstic Loc)です。楽観的ロックは、アノテーションを使って、すべてのエンティティにあらかじめ適用しておくことができます。

楽観的ロック

楽観的ロックでは、ロックが有効になるのは、トランザクションの最後、つまり、データがコミットされる時です。したがって、トランザクションの途中では、他のトランザクションが同じデータを読み込んだり、あるいは更新してコミットしてしまうこともあり得ます。

そこで、コミットする直前、同じレコードを他のトランザクションが更新していないかどうか調べます。ここで何もなければ、それでOK、データをコミットして終わりです。

しかし、運が悪ければ、他のトランザクションがすでにデータを更新したことが分かり、処理を継続できなくなります。OptimisticLockExceptionという例外を投げてトランザクションを失敗させ、変更はロールバックされて元の状態に戻されます。

では、どうやって他のトランザクションがデータを更新していたかを知るのか、ということですが、それには、エンティティの持つバージョンフィールドが大きな働きをします。

バージョンフィールド

エンティティクラスで、次のように、@Versionを付けたフィールドをバージョンフィールドといいます。バージョンフィールドの値は、エンティティのフィールドが更新された時、JPAプロバイダによって、自動的に+1されるフィールドです。

@Entity
public class Cart implements Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id = null;
    @Version
    private int version;
    private String cartNumber;
    @OneToMany(mappedBy = "cart", fetch = FetchType.LAZY, cascade=(CascadeType.ALL))
    private Set items = new HashSet<>();
    ・・・
}

トランザクションをコミットして、エンティティを更新する時、データベース上にあるエンティティのバージョンフィールドの値と、メモリー上にある(更新しようとしている)エンティティのバージョンフィールドの値が、JPAプロバイダによって比較されます。

値が同じなら、通常の更新処理が実行されます。一方、データベース上の値が大きい場合は、他のトランザクションがすでにエンティティを更新したことを意味するので、例外(OptimisticLockException)が投げられ、トランザクションはロールバックされます。

これがバージョンフィールドによる楽観的ロックの働きです。@Versionを使って、バージョンフィールドを作成するのは必須ではありませんが、特別な理由がない限り、使用するすべてのエンティティにバージョンフィールドを作成することを推奨します。

バージョンフィールドの特徴

バージョンフィールドには、short、int、 long、およびそれらのラッパークラス型(Short、Integer、Long)、それからjava.sql.Timestamp型を利用できますが、普通は、intを使います。

バージョンフィールドの値は、JPAプロバイダが更新しますが、大量一括更新処理に限り、自動的にはインクリメントされません。この場合は、クエリの中に次のような更新処理を書き加えて、手動で更新する必要があります。

UPDATE Employee e 
SET e.salary = 60000 , e.version=e.version + 1
WHERE e.salary = 55000

また、個別の更新処でも、バージョンフィールドが自動的に更新されるのは、他のエンティティのフィールドと関係を持たないフィールド(参照型でないフィールド)の値が変更された時と、参照型でも関係の所有者であるフィールドの値が変更された時です。

関係の所有者であるフィールドとは、Many-To-Oneの関係を持つエンティティや、One-To-Oneの関係で、外部キーを持つ方のエンティティ(=mapedbyが付かない方)をいいます(わかりにくい場合は、「わかりやすいJavaEE」の17章の解説を確認してください)。

バージョンフィールドの値が自動的には更新されないようなフィールドを集計したり、操作したりする場合は注意が必要です。というのも、前回解説した、非再現性リードやファントムリードなどの異常事態がそこで発生する可能性があるからです。

そのような場合は、バージョンフィールドによる自動的な楽観的ロックだけでは十分ではありません。自動的な楽観的ロックで防げるのは、ダーティリードまでなのです。そこで、トランザクションごとに、手動で適切なロックをかける必要があります。

手動でロックをかける方法

使用できるメソッド

自動的な楽観的ロックでは十分でないと考えられる場合は、次のメソッドを使って、手動でロックをかけることができます。その際、どのようなタイプのロックをかけるを指定できます。

メソッド 説 明
EntityManager#
lock(Object Entity, LockModeType type)
永続性コンテキストの中にあるエンティティに指定したロックをかける。よく使われるメソッドです。
EntityManager#
refresh(Object Entity, LockModeType type)
エンティティを永続性コンテキストの中に再読み込みして更新し、指定したロックをかける。
EntityManager#
find(Class T, Object key, LockModeType type)
キーでエンティティを検索し、指定したロックをかける。
Query#、TypedQuery#
setLocMode(LockModeType type)
指定したロックをかけてクエリを実行する。
@NamedQuery (
name=”・・・”  query=”・・・” lockMode=”・・・”
)
名前付きクエリ定義で、ロックモードを指定しておく。クエリは指定したロックをかけて実行される。

※通常の使用ではこれで十分ですが、各メソッドには、若干のバリエーションがあります。詳細を知りたい場合は、APIを参照してください。

使用できるロックモード

使用できるロックモードは次の4つです。LockModeTypeクラスのstatic変数として定義されている定数を使って指定します。

ロックモードを指定する定数 説 明
楽観的ロック
OPTIMISTIC
コミット時に、バージョン番号をチェックするので、他のトランザクションがすでに更新していると、コミットに失敗しロールバックする。
楽観的ロック
OPTIMISTIC-FORCE-INCREMENT
コミット時に、バージョン番号をチェックするので、他のトランザクションがすでに更新していると、コミットに失敗しロールバックする。

トランザクションの最後に、バージョン番号を+1する。エンティティを更新しなくてもバージョン番号が+1される。つまり、エンティティを実際に更新したのと同じ効果になる。

悲観的ロック
PESSIMISTIC-WRITE
エンティティのバージョンフィールドとは無関係。トランザクションの最初から、終了するまでの間、WRITEロックを取得する(他のトランザクションは書き込みできない)。悲観的ロックとして最も多く使用される。トランザクションを1つずつ逐次実行することになるので安全だが、処理効率がかなり悪くなる。
悲観的ロック
PESSIMISTIC-READ
エンティティのバージョンフィールドとは無関係。トランザクションの最初から、終了するまでの間、READロックを取得する(他のトランザクションは読み込みできない)。他に、同じエンティティを書き込むトランザクションがない、と想定される時に使えるが、そのようなケースはまれである。
悲観的ロック
PESSIMISTIC-FORCE-INCREMENT
バージョン番号に対応したエンティティにだけかけることができる。参照時にバージョン番号を強制的に+1する。それ以外は、PESSIMISTIC-READと同じ。

楽観的ロックは、排他制御をバージョンフィールドの値を見て判断します。読み込んだ時のversionバージョンフィールドの値が、コミット時に変更されていなければOKですが、変更されていればトランザクションを失敗させてロールバックする、という排他制御です。

これに対して、悲観的ロックは、DBMSの機能を利用したネイティブなロック機能ですから、書き込み権や読み取り権を排他的に取得する強力な機能です。ただ、実装は各ベンダーに任せられているので、ベンダーごとに挙動が異なる場合があります。

楽観的ロックを適切にかける判断は、すこし難しいかもしれません。この後、例を示しますが、適切にセットするには経験も必要です。これに対して、DBMSの機能を利用する悲観的ロックはわかりやすく、使い方も簡単です。そのため、多くのプログラマが悲観的ロック(特にPESSIMISTIC-WRITE)を選択しがちですが、本当に悲観的ロックが必要なケースは案外少ないと言われています。悲観的ロックの使用に際しては、十分検討して決定してください。

悲観的ロックでのタイムアウト設定

データベースアクセスを占有する悲観的ロックでは、タイムアウト設定が重要です。ただ、今のところJPAには設定するメソッドがないので、代わりに、クエリに実行時の付加情報としてセットします。多くのJPAベンダはこの方法に対応しています。なお、指定する時間単位はミリ秒(1/1000秒)です。

TypedQuery q = em.createQuery(
"SELECT e FROM Employee e WHERE e.id = 42", Employee.class);
q.setLockMode(LockMode.PESSIMISTIC-WRITE);
q.setHint("Javax.persistence.lock.timeout", 1000);

手動でロックをかける例示

バージョンフィールドの値が自動的には更新されないようなフィールドに注意しましょう。多くの場合そこが非再現的リードやファントムリードが発生するポイントです。一見、ロックをかける必要がないように見える処理に、落とし穴があります。そのようなフィールドを集計したり、操作したりする場合には注意が必要です。

例えば、すべての所属(Department)ごとに、社員の給与の合計を求める処理を考えましょう。この連載の1回目に掲載したドメインモデルをみてください。そこに、あるDepartmentエンティティは、社員(Employee)のリストを持っています。

employeesにはmappedByが付いており、関係の所有者ではありません。つまり、この値の読み書きでは、バージョンフィールドによるロック機構は働きません。

@Entity
public class Department {
    @Id @GeneratedValue(strategy=GenerationType.IDENTITY)
    private int id;
    private String name;
    @OneToMany(mappedBy="department")
    private Collection<Employee> employees;  // 社員のリスト
    ・・・
}

集計は、所属ごとにこのemployeesから社員(Employee)を取り出して、その給与を合計することにします。ちなみに、社員エンティティには、つぎのように給与フィールドがあります。

@Entity
public class Employee {
    @Id
    private int id;
    @Version private int version;
    private String name;
    private long salary; // 給与額
    ・・・
}

そして、トランザクションの中で、各所属ごとに給与を集計して、合計する処理は次のように書けます。

@Stateless
public class EmployeeService {
    @PersistenceContext(unitName="EmployeeService")
    private EntityManager em;
    public SalaryReport SalaryReport(List deptIds) { // 全所属のIDを受け取る
        SalaryReport report = new SalaryReport();
        long total = 0;
        for (Integer deptId : deptIds) {
            long deptTotal = totalSalary(deptId); // 1つの所属について集計
            total += deptTotal; // 総合計を求める
        }
        return total;
    }
    protected long totalSalary(int deptId) {
        long total = 0;
        Department dept = em.find(Department.class, deptId);
        for (Employee emp : dept.getEmployees()) {
            total += emp.getSalary(); // ここで所属ごとの集計をしている
        }
        return total; // 所属合計を返す
    }
    ・・・
}

さて、この処理を実行している途中で、別のスレッドが、一人の社員Aさんの所属を総務部から営業部に変更したとします。運の悪いことに、総務部まではすでに集計が終わり、次は営業部の集計と言う時点で、この変更処理がされたとしましょう(ロックがかかってないので、このような処理は起こり得ます)。

変更には、次のメソッドが使われました。このメソッドは、上のEmployeeService クラスにある別のメソッドで、em(エンティティマネージャー)はクラス内で共通のものです。

    ・・・
    // 所属の社員リストを更新し、社員エンティティも所属を変更する
    public void changeEmployeeDepartment(int deptId, int empId) {
        Employee emp = em.find(Employee.class, empId);
        emp.getDepartment().removeEmployee(emp);
        Department dept = em.find(Department.class, deptId);
        dept.addEmployee(emp);
        emp.setDepartment(dept);
    }
  ・・・

(注)findで検索されたエンティティは、永続性コンテキストの管理状態にあるので、トランザクション内で値を変更すると、明示的な書き込み処理をしなくても、メソッドの終了時に(トランザクションの終了時に)DBに変更が書き込まれます。

当然、Aさんは総務部で計上され、営業部でも計上されるので、このリポートは誤りということになります。

問題は、給与の集計中に、社員の所属を変更できてしましまったことです。どこででもロックがかからないので、この変更を検知する手段はありません。これを防ぐにはどうすればよいでしょう。

そうです、すくなくとも、社員の所属が変更されたことを検知して、社員変更か、あるいは、給与集計のどちらかのトランザクションが失敗するようにしておかねばなりません。つまり、どうにかして手動でロックをかけておけばよいわけです。

そこで、次のように、totalSalaryメソッドの中で、集計するemployeeエンティティにロックをかけることにします。

    protected long totalSalary(int deptId) {
        long total = 0;
        Department dept = em.find(Department.class, deptId);
        for (Employee emp : dept.getEmployees()) {
            em.lock(emp, LockModeType.OPTIMISTIC);
            total += emp.getSalary();
        }
        return total;
    }

これは、エンティティマネージャーに LockModeType.OPTIMISTIC のロックを指定しています。これにより、集計処理のトランザクション中は、employeeエンティティにロックがかかります。トランザクション終了時にバージョンフィールドを再チェックするので、ほかのトランザクションがエンティティを変更した場合は、それを検出し、集計処理トランザクションは失敗します。

LockModeType.OPTIMISTICは、トランザクションの最後(コミット時)に、バージョン番号を再読み込みして、最初に読み込んだ時と同じかどうかチェックすることが、ここでのポイントです。

どのようにロックをかけるかは、いろいろな方法があり、なかなか難しい問題です。ケースバイケースで処理の影響や関係を分析し、対処する必要があります。

読者になる

コメントをどうぞ

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

%d人のブロガーが「いいね」をつけました。