[Hibernate] lost update問題の解決 | Archive Redo Blog

Archive Redo Blog

DBエンジニアのあれこれ備忘録

同時実行ユーザー数の多いシステムでは、あるトランザクションで更新した内容が他のトランザクションによって上書きされて喪失してしまう、"lost update"が問題となることがあります。


特にアクセスの集中するテーブルにおいて起こりやすいこの問題も、Hibernateのバージョン管理機能を使うと比較的簡単に解決することができます。

Hibernateのバージョン管理機能

Hibernateのバージョン管理機能を利用するにはテーブルにバージョン管理用のカラムを追加する必要があります。


このバージョン管理用のカラムにはバージョン番号、またはタイムスタンプが使用できます。


これらのカラムがバージョン管理用のカラムであることをHibernateに認識させるためには、これらのカラムをマッピングファイルで<timestamp>または<version>として設定します。

MiddlegenではRDBMSにOracleを使っている場合、DATE型で定義されているカラムに対してフィールドマッピング特性に"timestamp"を指定できるようになっています。

("version"に関してはどのようにすれば指定できるのかがよくわかりませんでした。)

たったこれだけの設定でlost updateの問題を回避することができるようになります。

timestampを利用したlost update問題の解決例
Oracleでtimestampを利用した場合に、どのようにlost updateの問題が解決されるのかを簡単なプログラムを作成して実験してみます。

まず、"PRODUCT"というテーブルを作成し、その中に更新日時を格納するDATE型のカラム"LAST_MODIFIED"を作成します。

CREATE TABLE PRODUCT (
  ID            NUMBER(9,0) NOT NULL,
  NAME          VARCHAR2(100),
  PRICE         NUMBER(11,2),
  LAST MODIFIED DATE DEFAULT SYSDATE,
  CONSTRAINT PK_PRODUCTS PRIMARY KEY (ID) USING INDEX
)
/


そしてMiddlegenを実行して"PRODUCT"テーブルに対するマッピングファイルを作成します。


この時、"LAST_MODIFIED"カラムの[フィールドマッピング特性]に"timestamp"を指定すると、以下のようにマッピングファイルの<timestamp>要素が作成されます。

<timestamp
    name="lastModified"
    column="LAST_MODIFIED"
/>

さらにhbm2javaを実行して"PRODUCT"テーブルのJavaBeansを作成し、lost update問題が回避できるかどうかを確認するために以下のような"PRODUCT"テーブルを更新する簡単なJavaプログラムを作成します。

package sample;

import net.sf.hibernate.HibernateException;
import net.sf.hibernate.cfg.Configuration;
import net.sf.hibernate.Session;
import net.sf.hibernate.SessionFactory;
import net.sf.hibernate.Transaction;
import java.math.BigDecimal;

public class UpdateProduct {

   public static void main(String[] args) throws HibernateException {

        Configuration cfg = new Configuration();
        cfg.configure();
    
        SessionFactory factory = cfg.buildSessionFactory();
        Session s = factory.openSession();
        
        Product product = null;
        Transaction tx = null;
        
        try{
            tx = s.beginTransaction();
            System.out.println("------ 読込 ------");
	        product = (Product) s.load(Product.class,new Integer(1));
	
	        System.out.println("------ 更新 ------");
	        product.setPrice(product.getPrice().multiply(new BigDecimal(1.05)));
	        tx.commit();
        } catch (HibernateException e ) {
            e.printStackTrace();
		    tx.rollback();
        } finally {
            s.close();
        }
	        
    }
}


このプログラムはデバッグモードで実行し、レコードを読み込んだ時点で一時停止します。


そして、その間にこのプログラムが更新しようとしているレコードを別のプログラム、あるいはSQLを実行して更新します。


その後、一時停止したプログラムを再開すると、更新しようとしているレコードが他のプログラムによってすでに更新されているため、以下のような例外が発生します。

net.sf.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction
  (or unsaved-value mapping was incorrect) for sample.Product instance with identifier: 1

上記のプログラムで、"PRODUCT"レコードを更新する際に発行しているSQLは、以下のように更新対象レコードを絞り込むためのWHERE句にID(主キー)とともに"LAST_MODIFIED"カラムを加えたものとなっており、読み込んだ時と更新する時とで時刻が異なれば他のトランザクションによって更新されているという判断をしているようです。

update PRODUCT set LAST_MODIFIED=?, NAME=?, PRICE=? where ID=? and LAST_MODIFIED=?


そして、この機能を使えば更新時にバージョンのチェックを行うだけではなく、新しいバージョン番号をセットすることも同時にやってくれます。


ユーザーが特に意識しなくてもバージョン管理をやってくれるのです。

timestampを使う場合のリスク

このようにしてlost update問題を回避できるわけですが、timestampのカラムがDATE型の場合は精度が秒単位になってしまうので、1トランザクションの実行時間が極端に短く、かつ同時実行ユーザー数が多い場合は、それでもlost updateが起こってしまうリスクがあります。

このようなリスクを避けるためにはOracleのTIMESTAMP型を使いたいところですが、TIMESTAMP型のカラムをMiddlegenを使ってマッピングした場合はなぜか[フィールドマッピング特性]に"timestamp"を指定することができません。


以下のようにxxx-prefs.propertiesというMiddlegenの設定ファイルの内容を無理矢理改竄するとTIMESTAMP型のカラムを"timestamp"とすることができ、DATE型の場合と同じように動作することはするのですが、これではオートマチックにマッピングできるMiddlegenの良さを十分に活かせません。

# Middlegen Preferences
hibernate.tables.PRODUCT.base-class-name=Product
hibernate.tables.PRODUCT.columns.ID.columnspecialty=key
hibernate.tables.PRODUCT.columns.ID.java-name=id
hibernate.tables.PRODUCT.columns.ID.java-type=java.lang.Integer
hibernate.tables.PRODUCT.columns.LAST_MODIFIED.accessfield=property
hibernate.tables.PRODUCT.columns.LAST_MODIFIED.columnspecialty=timestamp
hibernate.tables.PRODUCT.columns.LAST_MODIFIED.genproperty=true
hibernate.tables.PRODUCT.columns.LAST_MODIFIED.insertable=true
hibernate.tables.PRODUCT.columns.LAST_MODIFIED.java-name=lastModified
hibernate.tables.PRODUCT.columns.LAST_MODIFIED.java-type=java.sql.Timestamp
hibernate.tables.PRODUCT.columns.LAST_MODIFIED.updateable=true
hibernate.tables.PRODUCT.columns.NAME.columnspecialty=property
hibernate.tables.PRODUCT.columns.NAME.java-name=name
hibernate.tables.PRODUCT.columns.NAME.java-type=java.lang.String
hibernate.tables.PRODUCT.columns.PRICE.columnspecialty=property
hibernate.tables.PRODUCT.columns.PRICE.java-name=price
hibernate.tables.PRODUCT.columns.PRICE.java-type=java.math.BigDecimal
hibernate.tables.PRODUCT.keygenerator=sequence
hibernate.tables.PRODUCT.keygeneratorarg=SQ_PRODUCT
tables.PRODUCT.x=4
tables.PRODUCT.y=4

OracleのTIMESTAMP型でも"timestamp"が選べるようMiddlegenが改善されればこんなことで悩む必要はないのかもしれませんが、今のところは同時実行性をシビアに要求されるシステムならMiddlegenの設定ファイルを改竄してTIMESTAMP型を使う、シビアに要求されないシステムならDATE型を使うという選択にならざるをえないようです。