樂觀鎖定(Optimistic Locking)


樂觀鎖定(Optimistic locking)樂觀的認為資料很少發生同時存取的問題,通常在資料庫層級上設為read-commited隔離層級,並實行樂觀鎖定。

在read commited隔離層級之下,允許交易讀取另一個交易已COMMIT的資料,但可能有unrepeatable read與lost update的問題存在,例如:
  1. 交易A讀取欄位1
  2. 交易B讀取欄位1
  3. 交易A更新欄位1並COMMIT
  4. 交易B更新欄位1並COMMIT

交易B可能基於舊的資料來更新欄位,使得交易A的資料遺失,或者是:
  1. 交易A讀取欄位1、2
  2. 交易B讀取欄位1、2
  3. 交易A更新欄位1、2,欄位1是新資料,欄位2是舊資料,並COMMIT
  4. 交易A更新欄位1、2,欄位1是舊資料,欄位2是新資料,並COMMIT

為了維護正確的資料,樂觀鎖定使用應用程式上的邏輯實現版 本控制的解決。

對於lost update的問題,可以有幾種選擇:
  • 先更新為主(First commit wins)
交易A先COMMIT,交易B在COMMIT時會得到錯誤訊息,表示更新失敗,交易B必須重新取得資料,嘗試進行更新。
  • 後更新的為主(Last commit wins)
交易A、B都可以COMMIT,交易B覆蓋交易A的資料也無所謂。
  • 合併衝突更新(Merge conflicting update)
先更新為主的變化應用,交易A先COMMIT,交易B要更新時會得到錯誤訊息,提示使用者檢查所有欄位,選擇性的更新沒有衝突的欄位。

Hibernate中透過版本號檢查來實現先更新為主,這也是Hibernate所推薦的方式,在資料庫中加入一個version欄位記錄,在讀取資料時 連同版本號一同讀取,並在更新資料時比對版本號與資料庫中的版本號,如果等於資料庫中的版本號則予以更新,並遞增版本號,如果小於資料庫中的版本號就丟出 例外。

實際來透過範例瞭解Hibernate的樂觀鎖定如何實現,首先在資料庫中新增一個表格:
create table user (
    id bigint not null auto_increment,
    version integer not null,
    name varchar(255),
    age bigint,
    primary key (id)
)


這個user表格中的version用來記錄版本號,以供Hibernate實現樂觀鎖定,接著設計User類別,當中必須包括version屬性:
  • User.java
package onlyfun.caterpillar;

public class User {
private Long id;
private int version; // 增加版本屬性
private String name;
private Long age;

public User() {}

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Long getAge() {
return age;
}

public void setAge(Long age) {
this.age = age;
}
}

在映射文件的定義方面,則如下所示:
  • User.hbm.xml
<?xml version="1.0" encoding="utf-8"?> 
<!DOCTYPE hibernate-mapping
PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd">

<hibernate-mapping>

<class name="onlyfun.caterpillar.User"
table="user"
optimistic-lock="version">

<id name="id" column="id">
<generator class="native"/>
</id>

<version name="version" column="version" access="field"/>

<property name="name" column="name"/>

<property name="age" column="age"/>

</class>

</hibernate-mapping>

注意<version>標籤必須出現在<id>標籤之後,接著您可以試著在資料庫中新增資料, 例如:
User user = new User();
user.setName("caterpillar");
user.setAge(new Long(30));

Session session = HibernateUtil.getSessionFactory().openSession();
Transaction tx =  session.beginTransaction();
session.save(user);
tx.commit();
session.close();


您可以檢視資料庫中的資料,每一次對同一筆資料進行更新,version欄位 的內容都會自動更新,接著來作個實驗,直接以範例說明:
// 有使用1者開啟了一個session1
Session session1 = HibernateUtil.getSessionFactory().openSession();
// 在這之後,馬上有另一個使用者2開啟了session2
Session session2 = HibernateUtil.getSessionFactory().openSession();
               
Long id = new Long(1);

Transaction tx1 = session1.beginTransaction();
Transaction tx2 = session2.beginTransaction();

// 使用者1查詢資料       
User userV1 = (User) session1.load(User.class, id);
// 使用者2查詢同一筆資料
User userV2 = (User) session2.load(User.class, id);
        
// 此時userV1、userV2兩個版本號是相同的
        
// 使用者1更新age
userV1.setAge(new Long(31));
// 使用者2資料更新name
// userV2 的 age 資料還是舊的
userV2.setName("justin");
        
// 交易1進行commit
tx1.commit();

// 此時由於資料更新,資料庫中的版本號遞增了
// 因版本號比資料庫中的舊
// 交易2送出更新資料會失敗,丟出StableObjectStateException 例外
tx2.commit();
               
session1.close();
session2.close();

運行以下的程式片段,會出現以下的結果:
Hibernate:
    select
        user0_.id as id0_0_,
        user0_.version as version0_0_,
        user0_.name as name0_0_,
        user0_.age as age0_0_
    from
        user user0_
    where
        user0_.id=?
Hibernate:
    select
        user0_.id as id0_0_,
        user0_.version as version0_0_,
        user0_.name as name0_0_,
        user0_.age as age0_0_
    from
        user user0_
    where
        user0_.id=?
Hibernate:
    update
        user
    set
        version=?,
        name=?,
        age=?
    where
        id=?
        and version=?
Hibernate:
    update
        user
    set
        version=?,
        name=?,
        age=?
    where
        id=?
        and version=?
12:34:16,531 ERROR AbstractFlushingEventListener:301 - Could not synchronize database state with session
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [onlyfun.caterpillar.User#1]

 
由於新的版本號是1,而userV2的版本號還是0,因此更新失敗丟出StableObjectStateException,您可以捕捉這個例外作善後 處理,例如在處理中重新讀取資料庫中的資料,同時將目前的資料與資料庫中的資料秀出來,讓使用者有機會比對不一致的資料,以決定要變更的部份,或者您可以 設計程式自動讀取新的資料,並比對真正要更新的資料,這一切可以在背景執行,而不用讓您的使用者知道。

如果不想透過版本號來進行控制,則也可以讓Hibernate用最後一次物件更新前的屬性,來與資料庫中的資料進行比對,確定是否有不一致的情況,如果沒有才進行更新,如果發現有不一致的情況,則丟出StaleObjectStateException。

例如:
  • User.java
package onlyfun.caterpillar;

public class User {
private Long id;
private String name;
private Long age;

public User() {}

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Long getAge() {
return age;
}

public void setAge(Long age) {
this.age = age;
}
}

User上不需要version屬性了,而映射文件上:
  • User.hbm.xml
<?xml version="1.0" encoding="utf-8"?> 
<!DOCTYPE hibernate-mapping
PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd">

<hibernate-mapping>

<class name="onlyfun.caterpillar.User"
table="user"
optimistic-lock="all"
dynamic-update="true"
>

<id name="id" column="id">
<generator class="native"/>
</id>

<property name="name" column="name"/>

<property name="age" column="age"/>

</class>

</hibernate-mapping>

optimistic-lock屬性設定為all,表示檢查最後一次更新前的所有屬性是否與資料庫中的欄位有不一致,如果執行先前的測試程式:
Hibernate:
    insert
    into
        user
        (name, age)
    values
        (?, ?)
Hibernate:
    select
        user0_.id as id0_0_,
        user0_.name as name0_0_,
        user0_.age as age0_0_
    from
        user user0_
    where
        user0_.id=?
Hibernate:
    select
        user0_.id as id0_0_,
        user0_.name as name0_0_,
        user0_.age as age0_0_
    from
        user user0_
    where
        user0_.id=?
Hibernate:
    update
        user
    set
        age=?
    where
        id=?
        and name=?
        and age=?
Hibernate:
    update
        user
    set
        name=?
    where
        id=?
        and name=?
        and age=?
12:48:15,015 ERROR AbstractFlushingEventListener:301 - Could not synchronize database state with session
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [onlyfun.caterpillar.User#1]

可以看到,Hibernate使用where子句,用最一次更新前的舊資料進行查詢,如果有符合才更新屬性,若否則丟出StaleObjectStateException。

要注意的是,由於樂觀鎖定是使用系統中的程式來控制,而不是使用資料庫中的鎖定機制,因而如果有人特意自行更新版本訊息來越過檢查,則鎖定機制就會無效, 例如在上例中自行更改userV2的version屬性,使之與資料庫中的版本號相同的話就不會有錯誤,像這樣版本號被更改,或是由於資料是由外部系統而 來,因而版本資訊不受控制時,鎖定機制將會有問題,設計時必須注意。