楽観ロックと理想的な管理方法
理想的なトランザクション管理とは
例えば『Service層』のあるメソッドで、以下の様な処理があったとします。
public void hoge() {
appLogic.insertA();
appLogic.insertB();
}
更にJPAには『Version管理』という『楽観ロック』を自動的に管理する事もできます。 それでは私が理想的と思うコードをご紹介します。
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
~ 省略 ~
@Version
private int version;
package dao;
import lombok.Data;
@Data
public class ReplyDao {
private Object clazz;
private boolean result;
private String jpaMessage = "";
private String sqlMessage = "";
private Throwable error;
}
例えばデータ登録処理でも色々知りたい事があります。
・登録された主キーは何?
・登録失敗した原因は何?
・登録失敗したメッセージは?
そこで役に立つのが『ReplyDao』です。
※ Reply = 返事 返事の内容は、好きなだけ追加できますが現状は以下を想定した設計とします。
・処理実行結果可否
・JPAが出力するエラーメッセージ
・SQLが出力するエラーメッセージ
・エラークラス
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;
}
}
『create』『update』『delete』は、基本的に同じ内容で重複する部分も多いので『update』で説明します。
その中の代表格が『更新日』『更新時刻』です。 更新処理の度にカラムに突っ込むのも面倒だし、無くしたらエラー調査時に困るしで。。
そこでエンティティに決まりを作ります。そう。 『全てのテーブルには同じ名前で「更新日」「更新時刻」カラムを作成する』です。 このルールのおかげで、どんなテーブルが来ても必ず値をセットする事が可能となります。
この恩恵はズバリ『try ~ catch』ができる事です。
catch側では以下を捕捉する事ができます。
・SQLエラー(PersistenceException)
・その他のエラー(Exception)
成功した場合、失敗した場合、または失敗の原因を全て格納できます。
ReplyDaoに格納する事で、メソッド自体は『void』でとてもシンプルとなります。
@Inject
protected UserDao userDao;
当然新たなDAO(HogeDao等)が増えたら、このクラスで管理します。
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;
}
}
DTOからエンティティを作成して、ReplyDao作ってと細かな処理をして以下メソッドを実施します。
『userDao.update(ent, reply);』
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』は全ての分岐内で、全て共通のメソッドとなります。
ただし、画面に表示するメッセージは状況によりけりなので、引数で渡す必要があります。
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エラー");
}
}
何と、たったの1行ですっ! これ可読性良いですよね。
・addFlushMessage 第2引数を成功時のメッセージとする
第3引数を成功時のメッセージとする
第4引数を成功時のメッセージとする
public String submit() {
appService.saveUser(dto);
return SELF; }
<!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 |
| 処理結果 | 成功 | 楽観ロック | エラー |
3度目は既存メールデータ『[email protected]』との一意制約違反です。 これで、JTAによるトランザクション管理『Service層』『Logic層』の感覚も掴めたと思います。
複合主キー
だって面倒ですよねw 単一の数字が最もシンプルです。
別に「カテゴリ番号」と「商品番号」を主キーにしたいって言われても、それ『ユニーク制約』入れれば良いだけですよね?
つまり主キーは番号だけの『捨てカラム』にする考えです。
※ surrogate key(サロゲートキー)という専門用語もあるくらいです 実際それを推奨しているフレームワークはほぼ全てと言っても良いくらいです。 ただし業務上やむを得ないケースもありますよね?
では以下の様なテーブルを作成してみましょう。
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 ('K0002', '2026-07-24', 'ふ~');
※作成方法は『エンティティを作成する』で勉強しましたね
・entity/ContractPK.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);
}
}
まずはDAOのインスタンスを作成します。
インスタンスは『CommonLogic』で管理していましたね。
@BaseLogic
@Dependent
public class CommonLogic implements Serializable {
private static final long serialVersionUID = 1L;
@Inject
protected UserDao userDao;
@Inject
protected ContractDao contractDao;
}
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()
複合主キーは少し面倒なのがご理解できたかと思います。
そして恐らく一番のネックは、
『主キーの値は変更ができない』です。





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