R e i - D r e a m
for Laravel
Guest
login

最終投稿日:2026年08月09日

楽観ロックと理想的な管理方法

理想的なトランザクション管理とは

トランザクション管理は、先ほども触れた様に『Service層』『Logic層』で管理するのが良いと思います。
例えば『Service層』のあるメソッドで、以下の様な処理があったとします。
Service層

public void hoge() {
    appLogic.insertA();
    appLogic.insertB();
}

この場合『Logic層』だけでトランザクション管理をすると、メソッド『insertA』が終了するとコミットされてしまいます。
また、テーブル管理にしても『登録日』や『更新日』または時間等を都度エンティティに設定するのも面倒ですよね。
更にJPAには『Version管理』という『楽観ロック』を自動的に管理する事もできます。
それでは私が理想的と思うコードをご紹介します。
entity/User.java

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;

~ 省略 ~

@Version
private int version;

○ @GeneratedValue(strategy = GenerationType.IDENTITY)
テーブル定義にある『自動附番』をエンティティ側にも登録します。
○ @Version
楽観ロック管理をしてくれる便利なアノテーションです。
楽観ロック

対義語となるのは『悲観ロック』です。 2つの違いはテーブル(またはレコード)に対してロックを掛けるかです。
ではどちらの管理がロックを掛けるのでしょう?
そりゃ当然『悲観ロック』ですよね。

ではこの管理をどう使い分けるのでしょうか。
例えばオンラインショッピングです。
お気に入りの商品をカートに入れて、いざ会計へ。
すると『カートの中身は空です』なんて言われたらたまったもんじゃないですよね。
内部的には、あなたが商品をカートに入れる時は『在庫データがあった』状態です。
しかし会計へ進む数秒の間に、誰かが『先に商品を購入し、在庫データが無くなった』のです。
何とも理不尽な。。
で、これを対策する場合は『悲観ロック』の出番となるのです。
あなたが商品をカートに入れた瞬間に『在庫データをロック』するのです。
すると第三者はその商品を操作する事ができません。
便利ですよね。
でもこれ管理側は面倒で、ロックされた商品は必ず買ってはくれません。
なのでロックを解除する処理が必要です。
もちろんそのタイミングはユーザーが商品をカートから捨てたらとなるのですが、そのまま放置されたらどうでしょうか?
店側は当然『機会損失』となり売上低下の原因となりますよね。
そこで『楽観ロック』の出番です。
ショッピングの様な場合は難しいですが、例えば会社内の特定の情報に対して、Aさんが編集中にBさんが同じ情報を更新したらどうでしょう?
その後、Aさんが更新したらBさんの情報が全て上書きされてしまいますよね。
別にそんな情報は再度編集すれば良いのですが、問題はAさんもBさんも『Bさんの情報が上書きされた事を知らない』事です。
『@Version』を付与するカラムは『数値型のカラム名[version]』と決まっています。
DB更新するとVersion管理されている情報は、カラム「version」が『+1』されるのです。
もうお分かりですね。 更新時に自身が更新したいデータの『version』が、現在DBにある『version』と相違がある場合に限りエラーとなるのです。
これが『悲観ロック』の正体です。

dao/ReplyDao.java

package dao;

import lombok.Data;

@Data
public class ReplyDao {
    private Object clazz;
    private boolean result;
    private String jpaMessage = "";
    private String sqlMessage = "";
    private Throwable error;
}

JAVAって返却値を自由に返せないですよね。
例えばデータ登録処理でも色々知りたい事があります。
・データ登録成功した?
・登録された主キーは何?
・登録失敗した原因は何?
・登録失敗したメッセージは?
ちょっと何型で返却すれば良いかわからないですよね(てか無理)。
そこで役に立つのが『ReplyDao』です。
※ Reply = 返事
返事の内容は、好きなだけ追加できますが現状は以下を想定した設計とします。
・エンティティ自体(登録の場合主キーが追加されます)
・処理実行結果可否
・JPAが出力するエラーメッセージ
・SQLが出力するエラーメッセージ
・エラークラス
dao/BaseDao.java

package dao;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.sql.Time;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import jakarta.persistence.EntityManager;
import jakarta.persistence.OptimisticLockException;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.PersistenceException;

public abstract class BaseDao<T, ID> implements Serializable {
    private static final long serialVersionUID = 1L;
    
    @PersistenceContext(unitName = "myMysqlUnit")
    protected EntityManager em;
    
    private final Class<T> entityClass;
    
    public BaseDao(Class<T> entityClass) {
        this.entityClass = entityClass;
    }
    // 保存
    public void create(T entity, ReplyDao reply) {
        try {
            Method setter;
            // 登録日をセット
            setter = entity.getClass().getMethod("setCreateYmd", Date.class);
            setter.invoke(entity, this.getDay());
            // 登録時刻をセット
            setter = entity.getClass().getMethod("setCreateHms", Time.class);
            setter.invoke(entity, this.getTime());
            
            em.persist(entity);
            em.flush();
            reply.setResult(true);
            reply.setClazz(entity);
        } catch(PersistenceException e) {
            reply.setResult(false);
            reply.setSqlMessage(getRootCauseMessage(e));
            reply.setError(e);
        } catch(Exception e) {
            reply.setResult(false);
            reply.setJpaMessage(e.getMessage());
            reply.setError(e);
        }
    }
    // 更新
    public void update(T entity, ReplyDao reply) {
        try {
            Method setter;
            // 更新日をセット
            setter = entity.getClass().getMethod("setUpdateYmd", Date.class);
            setter.invoke(entity, this.getDay());
            // 更新時刻をセット
            setter = entity.getClass().getMethod("setUpdateHms", Time.class);
            setter.invoke(entity, this.getTime());
            
            em.merge(entity);
            em.flush();
            reply.setResult(true);
            reply.setClazz(entity);
        } catch(PersistenceException e) {
            if (e instanceof OptimisticLockException) {
                reply.setSqlMessage("楽観ロックエラー");
                reply.setError(new OptimisticLockException());
            } else {
                reply.setSqlMessage(getRootCauseMessage(e));
                reply.setError(e);
            }
            reply.setResult(false);
        } catch(Exception e) {
            reply.setResult(false);
            reply.setJpaMessage(e.getMessage());
            reply.setError(e);
        }
    }
    // 削除
    public void delete(ID id, ReplyDao reply) {
        T ent = this.findById(id);
        try {
            em.remove(ent);
            em.flush();
            reply.setResult(true);
            reply.setClazz(ent);
        } catch(PersistenceException e) {
            reply.setSqlMessage(getRootCauseMessage(e));
            reply.setError(e);
            reply.setResult(false);
        } catch(Exception e) {
            reply.setResult(false);
            reply.setJpaMessage(e.getMessage());
            reply.setError(e);
        }
    }
    // 主キーによる検索
    public T findById(ID id) {
        Optional<T> ent = Optional.ofNullable(em.find(entityClass, id));
        if (ent.isEmpty()) {
            throw new RuntimeException("主キーが無い場合はガチエラーとする");
        }
        return ent.get();
    }
    // 全件検索
    public List<T> findAll() {
        String jpql = "SELECT e FROM " + entityClass.getSimpleName() + " e";
        return em.createQuery(jpql, entityClass).getResultList();
    }
    // SQLエラーメッセージを取得する
    private String getRootCauseMessage(Throwable throwable) {
        Throwable cause = throwable;
        // 原因(cause)を最後までたどる
        while (cause.getCause() != null && cause.getCause() != cause) {
            cause = cause.getCause();
        }
        // 最も深い場所にある例外(SQLExceptionなど)のメッセージを返す
        return cause.getMessage();
    }
    // 現在年月日を取得する
    private Date getDay() {
        LocalDate today = LocalDate.now();
        return Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }
    // 現在時刻を取得する
    private Time getTime() {
        Time currentTime = Time.valueOf(LocalTime.now());
        return currentTime;
    }
}

DAOの心臓部となります。
『create』『update』『delete』は、基本的に同じ内容で重複する部分も多いので『update』で説明します。
○ 更新日時の自動登録
基本的にテーブルってお決まりのカラムってありますよね。
その中の代表格が『更新日』『更新時刻』です。
更新処理の度にカラムに突っ込むのも面倒だし、無くしたらエラー調査時に困るしで。。
そこでエンティティに決まりを作ります。そう。
    『全てのテーブルには同じ名前で「更新日」「更新時刻」カラムを作成する』です。
このルールのおかげで、どんなテーブルが来ても必ず値をセットする事が可能となります。
○ SQLの即時発行
『em.flush();』を追記する事で、更新処理が遅延する事なく即時発行されます。
この恩恵はズバリ『try ~ catch』ができる事です。
catch側では以下を捕捉する事ができます。
・楽観ロックエラー(OptimisticLockException)
・SQLエラー(PersistenceException)
・その他のエラー(Exception)
○ ReplyDao
『try ~ catch』構造のおかげで『ReplyDao』が生きてきます。
成功した場合、失敗した場合、または失敗の原因を全て格納できます。
ReplyDaoに格納する事で、メソッド自体は『void』でとてもシンプルとなります。
logic/CommonLogic.java

@Inject
protected UserDao userDao;

共通Logicクラスは『DAO』インスタンスの管理のみです。
当然新たなDAO(HogeDao等)が増えたら、このクラスで管理します。
logic/SampleLogic.java

package logic;

import dao.ReplyDao;
import dto.SampleDto;
import entity.User;
import jakarta.enterprise.context.Dependent;
import jakarta.transaction.Transactional;

@Dependent
@Transactional(value = Transactional.TxType.REQUIRED, rollbackOn = {Exception.class})
public class SampleLogic extends CommonLogic{
    private static final long serialVersionUID = 1L;
    public ReplyDao saveUser(SampleDto dto) {
        User ent = new User();
        ent.setId(2);
        ent.setMail(dto.getName());
        ent.setPass("foo0000");
        ent.setName("foo太郎");
        ent.setVersion(Integer.valueOf(dto.getMemo()));
        
        ReplyDao reply = new ReplyDao();
        userDao.update(ent, reply);
        if(reply.isResult()) {
            User hoge = (User)reply.getClazz();
            System.out.println(hoge.getName());
        }
        System.out.println(reply.getSqlMessage());
        System.out.println(reply.isResult());
        
        return reply;
    }
}

Logic層は基本的にDB処理などの『泥臭い』処理専門です。
DTOからエンティティを作成して、ReplyDao作ってと細かな処理をして以下メソッドを実施します。
    『userDao.update(ent, reply);』
service/CommonService.java

package service;

import java.io.Serializable;
import annotation.BaseLogic;
import dao.ReplyDao;
import jakarta.enterprise.context.Dependent;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.application.FacesMessage.Severity;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.persistence.OptimisticLockException;
import jakarta.persistence.PersistenceException;
import logic.CommonLogic;

@Dependent
public class CommonService implements Serializable {
    private static final long serialVersionUID = 1L;
    @Inject
    @BaseLogic
    private CommonLogic commonLogic;
    
    // [ReplyDao]の内容からフラッシュメッセージを出力する
    protected void addFlushMessage(ReplyDao reply, String info, String warn, String err) {
        if (reply == null) {
            return;
        }
        String msg;
        Severity severityLevel;
        if (reply.isResult()) {
            severityLevel = FacesMessage.SEVERITY_INFO;
            msg = info;
        } else {
            if (reply.getError() instanceof OptimisticLockException) {
                severityLevel = FacesMessage.SEVERITY_WARN;
                msg = warn;
            } else if (reply.getError() instanceof PersistenceException) {
                severityLevel = FacesMessage.SEVERITY_ERROR;
                msg = err;
            } else {
                severityLevel = FacesMessage.SEVERITY_ERROR;
                msg = "予期せぬエラーです。";
            }
        }
        FacesMessage resultMsg = new FacesMessage(severityLevel, msg, null);
        FacesContext context = FacesContext.getCurrentInstance();
        context.addMessage(null, resultMsg);
    }
}

サービス層です。
今回はよく見る、DB処理の結果で画面にメッセージが表示される仕組みにしてみました。
そこで共通サービスクラスには『DB処理専用のメッセージ出力関数』を作成します。
第1引数である『ReplyDao』は全てのテーブルで共通にしてるために実現できます。
つまりインスタンス『reply』は全ての分岐内で、全て共通のメソッドとなります。
ただし、画面に表示するメッセージは状況によりけりなので、引数で渡す必要があります。
service/SampleService.java

package service;

import dto.SampleDto;
import jakarta.enterprise.context.Dependent;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import logic.SampleLogic;

@Dependent
@Transactional(value = Transactional.TxType.REQUIRED, rollbackOn = {Exception.class})
public class SampleService extends CommonService {
    private static final long serialVersionUID = 1L;
    @Inject
    private SampleLogic appLogic;
    
    public void saveUser(SampleDto dto) {
        // DTOの内容から更新処理を実施

        addFlushMessage(appLogic.saveUser(dto), "成功~", "楽観ロックエラー", "SQLエラー");
    }
}

共通サービスクラスに共通処理を記述したので、個別サービスクラスである『SampleService』は、
    何と、たったの1行ですっ!
これ可読性良いですよね。
・appLogic.saveUser(dto)            DTOの中身を保存して、その返却を第1引数にする
・addFlushMessage                         第2引数を成功時のメッセージとする
                                                                    第3引数を成功時のメッセージとする
                                                                    第4引数を成功時のメッセージとする
        ※メッセージは「InfomationMessages.properties」などで管理するとスマートです
view/SampleBean.java

public String submit() {
    appService.saveUser(dto);

    return SELF; }

当然1行です。
sample.xhtml

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
    xmlns:h="jakarta.faces.html"
    xmlns:f="jakarta.faces.core"
    xmlns:p="jakarta.faces.passthrough">
    <f:metadata>
        <f:event type="preRenderView" listener="#{sampleBean.init}" />
    </f:metadata>
<h:head>
    <title>Hello World</title>
    <h:outputStylesheet library="css" name="style.css" />
    <h:outputScript library="js" name="base.js" />
</h:head>
<h:body>
    <h1>おはよう世界!</h1>
    <h:form id="sample_form" prependId="false">
        <h:messages errorClass="alert-danger" warnClass="alert-warning" infoClass="alert-info" layout="list" />
        
        
<h:inputText id="inpText" value="#{sampleBean.dto.name}" style="width:300px;height:25px;"
                styleClass="#{not empty facesContext.getMessageList('inpText') ? 'parts_text_err' : 'parts_text'}" />
        <BR />
        <h:inputText id="inpMemo" value="#{sampleBean.dto.memo}"/>
        <BR />
        
        <h:commandButton value="自画面遷移" actionListener="#{sampleBean.submit()}" />
        
    </h:form>
</h:body>
</html>

以上で設定は完了です。
どうでしょう。上手く動作してますか?
仕様は以下となります。
今回は特定のレコードを『更新』する仕様となるため『SampleLogic.java』で指定しているIDレコードを追加します。
    xinsert into users (id, mail, pass, name) values (2, 'hoge@hoge.com', 'hoge1234', 'hoge太郎');
以下3回のテストで『成功』『楽観ロック』『エラー』のメッセージが表示されます。
1回目 2回目 3回目
テキストボックス上段 [email protected] [email protected] [email protected]
テキストボックス下段 0 0 1
処理結果 成功 楽観ロック エラー
2度目は『version』の数値がDB側と一致しないためです。
3度目は既存メールデータ『[email protected]』との一意制約違反です。
これで、JTAによるトランザクション管理『Service層』『Logic層』の感覚も掴めたと思います。

複合主キー

基本的にフレームワークは複合主キーを嫌います。
だって面倒ですよねw
単一の数字が最もシンプルです。
別に「カテゴリ番号」と「商品番号」を主キーにしたいって言われても、それ『ユニーク制約』入れれば良いだけですよね?
つまり主キーは番号だけの『捨てカラム』にする考えです。
    ※ surrogate key(サロゲートキー)という専門用語もあるくらいです
実際それを推奨しているフレームワークはほぼ全てと言っても良いくらいです。
ただし業務上やむを得ないケースもありますよね?
では以下の様なテーブルを作成してみましょう。
DDL

CREATE TABLE contracts (
kei_no              CHAR(5) NOT NULL                        ,
start_ymd         date                                                  ,
name                VARCHAR(20)                                 ,
version              int                      DEFAULT 0            ,
create_ymd      date                                                  ,
create_hms      TIME                                                ,
update_ymd     date                                                  ,
update_hms     TIME                                                 ,
del_sn              CHAR(1)            DEFAULT ' '            ,
PRIMARY KEY (kei_no, start_ymd)
) ENGINE=InnoDB;

以下テストデータを登録します。
insert into contracts (kei_no, start_ymd, name) values ('K0001', '2026-07-20', 'ほげ');
insert into contracts (kei_no, start_ymd, name) values ('K0002', '2026-07-24', 'ふ~');
それではエンティティを作成してみて下さい。
    ※作成方法は『エンティティを作成する』で勉強しましたね
以下2つのクラスが作成されます。
・entity/Contract.java
・entity/ContractPK.java
ほら、面倒でしょw
エンティティを作成したら、次は『ContractDao.java』を作成する必要があります。
ContractDao.java

package dao;

import entity.Contract;
import entity.ContractPK;
import jakarta.enterprise.context.Dependent;

@Dependent
public class ContractDao extends BaseDao<Contract, ContractPK>{
    private static final long serialVersionUID = 1L;
    public ContractDao() {
        super(Contract.class);
    }
}

BaseDaoに渡す「型引数」の2番目は『主キーのデータ型』となるため『ContractPK』とします。
ではこのテーブルを主キー検索してみます。
まずはDAOのインスタンスを作成します。
インスタンスは『CommonLogic』で管理していましたね。
CommonLogic.java

@BaseLogic
@Dependent
public class CommonLogic implements Serializable {
    private static final long serialVersionUID = 1L;
    
    @Inject
    protected UserDao userDao;

    @Inject
    protected ContractDao contractDao;
}

では実際の主キー検索のコードを『SampleLogic』に書いてみます。
SampleLogic.java

public ReplyDao saveUser(SampleDto dto) {
    LocalDate localDate = LocalDate.of(2026, 7, 24);
    Date date = Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
    

    ContractPK pk = new ContractPK();
    pk.setKeiNo("K0002");
    pk.setStartYmd(date);
    Contract ent = contractDao.findById(pk);
    
    System.out.println(ent.getId().getKeiNo());
    System.out.println(ent.getName());
    
    return null;
}

主キークラスのインスタンスを作成し、値を詰めます。
そしてそれを、主キー検索メソッド『BaseDao.findById』の引数へ渡します。
更に取得した情報から主キー情報を参照するには、以下の様な冗長なコードとなります。
    ent.getId().getKeiNo()
ポイント

複合主キーは少し面倒なのがご理解できたかと思います。
そして恐らく一番のネックは、
    『主キーの値は変更ができない』です。

ほぼ全てのフレームワークはこの仕様と思ってください。
以上、新たにシステムを設計する場合の参考になれば幸いです。

ログインしてコメントを残そう!!


きっぷる