Old-School Backend / 2026 ・ No.09

Old-School Backend

Part 9: Transactions and concurrent updates — the last save wins

Published

A terminal. In BudgetMapper.xml, setRemaining is update budget set remaining = #{remaining} where dept = #{dept}, and subtract is update budget set remaining = remaining - #{amount} where dept = #{dept} and remaining >= #{amount}. python3 show9.py race shows naive with 150 replies of 200, 150,000 yen approved, 17,000 yen taken from the budget, 960 ms; atomic, locked and retry each with 100 of 200 and 50 of 409, 100,000 yen taken, in 487, 700 and 955 ms
150 simultaneous 1,000-yen spends against a 100,000-yen budget. The read-subtract-write naive way said OK to all 150, yet the budget went down by only 17,000 yen (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)

In a business system, several people touch the same claim or the same budget at once. Tested alone, everything works; with two people, the numbers stop adding up, and no error appears.
This time, with MyBatis on PostgreSQL, we measure an overwrite on screen, 150 simultaneous spends against one budget, a deadlock and an overlong transaction, and compare four fixes: subtracting in SQL, locking the row, a version number, and retrying.

1

The last save wins

AraiI put @Transactional on it, so simultaneous requests are fine, right?

Furui@Transactional guarantees “all or nothing”. “At the same time as someone else” is a different story.

AraiA different story…

FuruiToday the two of us will edit the same claim, and 150 people will spend from the same budget.

The 15-part route map (you are here: part 9)

1 Big picture 2 Java basics 3 Raw HTTP 4 Raw JDBC 5 Spring Boot 6 Validation 7 Errors 8 DB access 9 Transactions 10 Workflow 11 Auth 12 Files 13 Logging 14 Testing 15 Running it You are here Basics By hand Front door Data Rules & safety Operations
  • An overwrite on screen — both got 200, yet the first saver's change vanished (measured)
  • 150 at once — all 150 told OK, and the budget fell by only 14,000–18,000 yen (measured)
  • Four fixes — subtract in SQL, for update, a version, isolation level plus retry
  • Deadlock — two opposite transfers; one got a 500 after 1.2 s (measured)
  • A long transaction — wait 1 s inside it and the 12th request takes 12 s (measured)

Following last part's comparison, the storekeeper is MyBatis from now on. JPA is gone from the application (MyBatis 3.5.19, mybatis-spring-boot 4.1.0); the earlier counters keep JdbcClient, and the new ones are written with MyBatis.

# The toolkit (part 8's JPA + MyBatis, with JPA removed)
  part 8: jar 55,029,971 bytes, libraries 75
  part 9: jar 26,299,833 bytes, libraries 52, startup 3.945 s / 4.119 s / 4.058 s

In the database we added a departmental budget table, budget (100,000 yen each for dev and sales), and a version column, version, on the claims table expense.

2

An overwrite on screen

First, the most common shape. Arai and Furui open the same business-trip claim (EXP-2026-5001, 24,160 yen) on the edit screen. Arai adds a 1,500-yen taxi home and saves. Furui adds the destination “(Osaka)” to the item and saves. The counter simply updates with whatever item and amount arrive.

# Arai and Furui open the same claim and edit it (no version)
  Arai   open        200  item=出張-001  amount=24,160  version=0
  Furui  open        200  item=出張-001  amount=24,160  version=0
  Arai   save        200  item=出張-001  amount=25,660  version=0
  Furui  save        200  item=出張-001(大阪)  amount=24,160  version=0
  DB     look later  200  item=出張-001(大阪)  amount=24,160  version=0

An overwrite on screen (measured)

① open ② open ③ save ④ save Arai Furui DB open 24,160 save 25,660 open 24,160 save (Osaka) 24,160 24,160 出張-001 24,160 出張-001 25,660 出張-001 24,160 出張-001(大阪) Furui sent back the 24,160 he saw; the 1,500-yen taxi is gone Both got 200: no error, no log. Found when accounts checks the totals Minutes pass between open and save: no DB lock can span that

Furui's screen still held the 24,160 yen from when he opened it, and that went straight back. Arai's taxi vanished, yet both got 200, with no error and no log line. This is “the last save wins” (a lost update).

Minutes can pass between opening and saving. You can't hold a database transaction open that long, so database locks can't prevent this. Chapter 6 fixes it with a version number.

3

150 spends at once

Next, a much shorter time span. We wrote a clerk that subtracts each claim's amount from the department's budget: read what's left, check it's enough, write the new value. It has @Transactional, too.

# ① Read, subtract, write (BudgetService.java and BudgetMapper.xml)
@Transactional  // ① read, subtract, write
public void spendNaive(String dept, BigDecimal amount) {
    Budget b = budgets.find(dept);
    if (b.remaining().compareTo(amount) < 0) throw new BudgetShortException(dept);
    budgets.setRemaining(dept, b.remaining().subtract(amount));
}

<select id="find" resultType="example.expenses.v9.Budget">
  select dept, remaining, version from budget where dept = #{dept}
</select>
<update id="setRemaining">
  update budget set remaining = #{remaining} where dept = #{dept}
</update>

We sent 150 simultaneous 1,000-yen spends against dev's 100,000-yen budget. Done right, 100 go through, the budget ends at 0 yen, and the other 50 get “not enough budget” (409). For comparison, the fixes from chapters 4 and 5 are listed too.

# 150 simultaneous 1,000-yen spends against a 100,000-yen budget (three runs each)
  way           200  409  500   approved        taken from budget   time
  naive         150    0    0   150,000 yen     14,000 yen          606 ms
                150    0    0   150,000 yen     18,000 yen          557 ms
                150    0    0   150,000 yen     18,000 yen          441 ms
  atomic        100   50    0   100,000 yen     100,000 yen         378 ms
                100   50    0   100,000 yen     100,000 yen         323 ms
                100   50    0   100,000 yen     100,000 yen         311 ms
  locked        100   50    0   100,000 yen     100,000 yen         405 ms
                100   50    0   100,000 yen     100,000 yen         380 ms
                100   50    0   100,000 yen     100,000 yen         363 ms

① said OK to all 150: 150,000 yen approved, 50,000 over budget. Yet only 14,000–18,000 yen left the budget. The money doesn't add up in either direction.

What happens inside ①

Inside ①: transactions A and B read and write the same budget row A B select sees 50,000 update → 49,000 commit select sees 50,000 update waits → 49,000 DB 50,000 50,000 49,000* 49,000 Two spends, only 1,000 yen gone (* = A's uncommitted value) ② subtract in SQL: after waiting, B takes from the latest 49,000 ③ for update: B's select waits for A and reads 49,000 ④ REPEATABLE READ: B's update is refused (40001) → retry The update waits for the lock, then writes the value B computed At READ COMMITTED (PostgreSQL's default) B can't see A's draft

Under PostgreSQL's default isolation level, READ COMMITTED, a select reads the values committed at that moment. A and B both read 50,000 yen and each computes 49,000. B's update waits for A's lock to go, but what it then writes is the 49,000 B computed. @Transactional undoes everything if something fails midway; it doesn't order you against other transactions.

AraiWhen I tested it alone, it went down by 1,000 yen every time.

FuruiAlone, you never overlap anyone. Bugs like this sleep through testing.

4

Subtract inside the SQL

The simplest fix is not to compute in Java but to subtract inside the SQL, with the “is it enough?” check in the same update's where clause.

# ② Subtract inside the SQL
<update id="subtract">
  update budget set remaining = remaining - #{amount}
  where dept = #{dept} and remaining &gt;= #{amount}
</update>

@Transactional  // ② subtract inside the SQL
public void spendAtomic(String dept, BigDecimal amount) {
    if (budgets.subtract(dept, amount) == 0) throw new BudgetShortException(dept);
}

After waiting for A, B's update rechecks the where clause and subtracts from the latest value (49,000 yen). If there isn't enough, zero rows are updated, and we turn that into “not enough budget”. Result: 100 of 200, 50 of 409, and the budget at exactly 0 yen, with a single statement and slightly faster than ① (311–378 ms).

When you see “read the value, compute in Java, write it back”, first ask whether SQL can do it. The set column = column - #{value} form works as is for stock, balances, counters and anything else that goes up and down.

5

Lock the row

Sometimes the check won't fit in one where clause, say “read the department's rules, read the applicant's grade, then decide”. Then lock the row as you read it: select … for update.

# ③ Lock the row, then read (for update)
<select id="findForUpdate" resultType="example.expenses.v9.Budget">
  select dept, remaining, version from budget where dept = #{dept} for update
</select>

@Transactional  // ③ lock the row, then read
public void spendLocked(String dept, BigDecimal amount) {
    Budget b = budgets.findForUpdate(dept);
    if (b.remaining().compareTo(amount) < 0) throw new BudgetShortException(dept);
    budgets.setRemaining(dept, b.remaining().subtract(amount));
}

Once A has read a row for update, B's for update waits until A commits. B then reads the latest 49,000 yen, and the arithmetic is right. Same result as ②: 100 and 50, 0 yen (363–405 ms).

The lock stays until the transaction ends. Do something slow while holding it, and everyone else queues behind you (chapter 9).

6

Notice with a version

Back to chapter 2's overwrite. No lock can last the minutes a screen stays open. So each row gets a version number. The screen sends back the version it opened, the update's where clause checks it's still the same version, and a successful write bumps it by one.

A terminal. In ClaimEditMapper.xml, updateIfSameVersion is update expense set item = #{item}, amount = #{amount}, version = version + 1 where id = #{id} and version = #{version}. In show9.py edit naive, Arai saves 25,660, then Furui saves 出張-001(大阪) with 24,160, and the database holds 24,160. In edit checked, Furui's save gets 409 ほかの人が先に変更しました; reopening shows 25,660 version=1, and saving gives 出張-001(大阪) 25,660 version=2
Without a version, Furui saved last and wiped Arai's change (top). With a version, Furui got a 409, reopened and saved, and both changes stayed (bottom) (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
# An update with a version (ClaimEditMapper.xml and ClaimEditService.java)
<update id="updateIfSameVersion">
  update expense set item = #{item}, amount = #{amount}, version = version + 1
  where id = #{id} and version = #{version}
</update>

@Transactional  // write only if the version is the one the screen opened
public ClaimEdit updateChecked(String id, EditRequest req) {
    if (claims.updateIfSameVersion(id, req.item(), req.amount(), req.version()) == 0) {
        find(id);  // missing → 404
        throw new StaleClaimException(id);  // exists → someone changed it first
    }
    return claims.find(id);
}
# The handler turns it into a 409 (added to ApiErrors.java)
@ExceptionHandler(StaleClaimException.class)  // someone changed it after you opened it → 409
ProblemDetail stale(StaleClaimException e) {
    return problem(HttpStatus.CONFLICT, "stale", "ほかの人が先に変更しました", "受付番号 " + e.id() + " を開き直して、もう一度変更してください");
}
# The same steps, with a version
  Arai   open        200  item=出張-001  amount=24,160  version=0
  Furui  open        200  item=出張-001  amount=24,160  version=0
  Arai   save        200  item=出張-001  amount=25,660  version=1
  Furui  save        409  ほかの人が先に変更しました
  Furui  reopen      200  item=出張-001  amount=25,660  version=1
  Furui  save        200  item=出張-001(大阪)  amount=25,660  version=2
  DB     look later  200  item=出張-001(大阪)  amount=25,660  version=2

Arai's save moved the version from 0 to 1, so Furui's update, still sent with version 0, touched zero rows. The counter turned that into a 409 saying “reopen it and change it again”. When Furui reopened it, he saw 25,660 yen including the taxi, added the destination, saved, and both changes stayed.

This finds the clash when writing instead of locking, so it's also called optimistic locking; the previous chapter's for update locks first and is pessimistic locking. Use optimistic locking for edits that span screens, and ② or ③ for arithmetic inside one transaction.

AraiI feel a bit sorry for whoever gets the 409.

FuruiFar better than being silently erased. The screen says “someone changed this first” and shows the latest.

7

Isolation levels and retrying

We kept ①'s code and only raised the transaction's isolation level to REPEATABLE READ.

# ④ Same as ①, at REPEATABLE READ
@Transactional(isolation = Isolation.REPEATABLE_READ)  // ④ same as ①, higher isolation level
public void spendRepeatable(String dept, BigDecimal amount) {
    Budget b = budgets.find(dept);
    if (b.remaining().compareTo(amount) < 0) throw new BudgetShortException(dept);
    budgets.setRemaining(dept, b.remaining().subtract(amount));
}

  way           200  409  500   approved        taken from budget   time
  repeatable     71    0   79   71,000 yen      71,000 yen          557 ms
                 47    0  103   47,000 yen      47,000 yen          510 ms
                 48    0  102   48,000 yen      48,000 yen          493 ms
# What the 500s were (app log; the same in 100 of 150)
org.springframework.dao.CannotSerializeTransactionException:
org.postgresql.util.PSQLException: ERROR: could not serialize access due to concurrent update

No more OKs beyond the budget, but 79–103 of the 150 got a 500. Under REPEATABLE READ a transaction keeps working from the values it saw at the start. If someone else changed the row first, PostgreSQL refuses the update with “could not serialize access due to concurrent update” (SQLState 40001), and its documentation says the application should retry the whole transaction from the beginning.

# ⑤ Refused? Start over (RetryingSpender.java)
public int spend(String dept, BigDecimal amount) {
    for (int attempt = 1; ; attempt++) {
        try {
            service.spendRepeatable(dept, amount);  // a new transaction each time
            return attempt;
        } catch (ConcurrencyFailureException e) {
            if (attempt == 10) throw e;
            log.debug("retry {}: {}", attempt, e.getClass().getSimpleName());
            BudgetService.pause(ThreadLocalRandom.current().nextLong(5, 20) * attempt);  // wait a little first (spread the waits)
        }
    }
}

  way           200  409  500   approved        taken from budget   time
  retry         100   50    0   100,000 yen     100,000 yen         626 ms
                100   50    0   100,000 yen     100,000 yen         578 ms
                100   50    0   100,000 yen     100,000 yen         584 ms
# Attempts (total over the 100 successes / most for one)
  232 / 5   203 / 5   244 / 6

Add a helper that waits a little and starts over when refused, and we get 100 and 50 with the budget at 0 yen. The 100 successes took 203–244 attempts in all; the slowest got through on its sixth. Each retry must be a fresh transaction, so the loop goes outside the @Transactional method. Spring Framework 7 also gained @Retryable, which does the same (by default up to 3 retries, 1 second apart).

FixThis part's resultGood for; watch out for
② subtract in SQLcorrect, 311–378 mscounts going up and down; the check must fit in where
③ for updatecorrect, 363–405 msread, then decide in Java; hold the lock briefly (ch. 9)
④ REPEATABLE READ79–103 got 500not on its own; assumes a retry on refusal
⑤ ④ + retrycorrect, 578–626 msmany rows, hard to lock; a retry limit, spread-out waits
version409 if changed firstedits that span screens; the screen must send the version back
8

Deadlock

Once you use locks, you meet another problem. We wrote a budget transfer (10,000 yen from dev to sales) and ran it alongside the opposite transfer. To make it easy to see, it waits 0.2 s between subtracting and adding.

A terminal. At the top, the database log: ERROR: deadlock detected; DETAIL: Process 11422 waits for ShareLock on transaction 17717; blocked by process 11430.; CONTEXT: while updating tuple (0,27) in relation budget; STATEMENT: update budget set remaining = remaining + $1 where dept = $2. At the bottom, show9.py dead: with ordered=false, dev→sales got 200 in 1444 ms and sales→dev 500 in 1589 ms; with ordered=true, 242 and 450 ms, both 200
Two opposite transfers between dev and sales at once. Locking in any order, the database found a deadlock and cancelled one; locking in name order, both went through (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
# Budget transfer (BudgetService.java)
@Transactional  // budget transfer: take from 'from', add to 'to'
public void transfer(String from, String to, BigDecimal amount, long pauseMs, boolean ordered) {
    if (ordered) {  // always lock in name order
        budgets.findForUpdate(from.compareTo(to) < 0 ? from : to);
        budgets.findForUpdate(from.compareTo(to) < 0 ? to : from);
    }
    if (budgets.subtract(from, amount) == 0) throw new BudgetShortException(from);
    pause(pauseMs);
    budgets.add(to, amount);
}
# dev→sales and sales→dev at once (10,000 yen; add 0.2 s after subtracting)
  ordered=false
    dev→sales  500   1220 ms  処理できませんでした  ERR-688287aa
    sales→dev  200   1218 ms  OK
    budget: dev 110,000, sales 90,000
  ordered=true
    dev→sales  200    413 ms  OK
    sales→dev  200    209 ms  OK
    budget: dev 100,000, sales 100,000
# The database log at that moment
09:08:01.035 JST [2605] ERROR:  deadlock detected
09:08:01.035 JST [2605] DETAIL:  Process 2605 waits for ShareLock on transaction 13552; blocked by process 2592.
09:08:01.035 JST [2605] CONTEXT:  while updating tuple (0,10) in relation "budget"
09:08:01.035 JST [2605] STATEMENT:  update budget set remaining = remaining + $1 where dept = $2

Deadlock: grabbing locks in opposite orders

dev → sales transfer ① sales → dev transfer ② budget: dev budget: sales holds waits holds waits ① took dev and waits for sales; ② took sales and waits for dev PostgreSQL waits 1 s (deadlock_timeout), finds the circle, cancels one The fix: lock in one fixed order. In name order, both got 200 Measured: any order → one 500 (1.2 s); name order → 200 in 0.2/0.4 s The cancelled one is a ConcurrencyFailureException: retryable

① locks the dev row and waits for sales; ② locks sales and waits for dev. Each waits for the other to finish, forever. PostgreSQL waited 1 second (the default deadlock_timeout), looked for the circle and cancelled one. The cancelled one got a 500; the other got 200 after 1.2 s. The budgets moved by one transfer only.

The fix is to always take locks in the same order. Here we first lock both rows for update in name order, and both transfers got 200 (0.2 s and 0.4 s). The cancelled side's exception (DeadlockLoserDataAccessException) belongs to the same ConcurrencyFailureException family as in chapter 7, so it can be retried too.

9

Keep transactions short

Finally, how long a lock is held. After the spend we added “send a notice email” (here just a 1-second wait), and compared waiting inside the transaction with waiting after commit: 12 spends against the same budget at once, plus, 0.3 s later, one read that opens an unrelated claim.

# Waiting for the 1-second notice inside the transaction, or after commit
@Transactional  // after subtracting, wait for the notice inside the transaction
public void spendThenWaitInside(String dept, BigDecimal amount, long notifyMs) {
    spendAtomic(dept, amount);
    pause(notifyMs);  // pretend to send a notice email
}

case "notify-after" -> {
    budgets.spendAtomic(dept, amount);  // commit first
    BudgetService.pause(notifyMs);  // then wait for the notice
}
# 12 at once, seconds until each reply (+ one unrelated claim opened 0.3 s later)
  notify-inside 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0 9.0 10.0 11.0 12.0
                unrelated claim: 2.73 s
  notify-after  1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
                unrelated claim: 0.00 s

Where you wait for the notice changes the waiting like this (measured)

Waiting for the notice inside the transaction 12 (dots: wait) unrelated read 2.73 s 12.0 s Committing first, then waiting for the notice 12 spends unrelated read < 0.01 s 1.0 s 0 2 4 6 8 10 12 s Don't wait on slow things while holding a lock and a connection The pool has 10; past that, even an unrelated read waited

Waiting inside means waiting 1 s while holding the budget row's lock, so the 12 queue one by one and the last takes 12.0 s. Worse, a read that only opens a claim unrelated to the budget waited 2.73 s. The one holding the lock and the nine waiting for it each held a database connection, filling the pool of 10. Waiting after commit, all 12 took 1.0 s and the unrelated read under 0.01 s.

Move anything that might be slow, such as email, external APIs or writing files, outside the transaction. In Spring, @TransactionalEventListener can also run it “after commit”.

POINT

Concurrent updates aren't a rare accident; they happen every day from the moment you have two users. When you see read, compute, write, ask whether SQL can compute it, whether you need a lock, whether you need a version. And hold locks as briefly as you can.

10

Checking on your own PC

What to tryHow to write it
See the current isolation levelshow default_transaction_isolation;
Cause an overwrite by hand with two psql sessionsbegin; select … ; update … ; commit; (alternating windows)
See who is waiting for whomselect pid, pg_blocking_pids(pid), query from pg_stat_activity;
Time before a deadlock is detectedshow deadlock_timeout;
Find read-then-write codegrep -rn 'set .* = #{' src/main/resources

Open two psql windows, begin; in both, select the same row, then update it in each: the second update stops. The moment you commit; in the first, the second moves on. Once you've seen that “stop, then go” with your own eyes, the logs start to make sense.

§ — On one page, and next time

  1. @Transactional means all or nothing; it doesn't order you against simultaneous users.
  2. Read, compute, write: all 150 told OK, and the budget fell by only 18,000 yen, with no error.
  3. Do counting inside the SQL, with the check in the where clause.
  4. Read then decide: for update. Across screens: a version and a 409.
  5. REPEATABLE READ refuses. When refused, start over.
  6. Take locks in one fixed order, and briefly.

AraiI had no idea “at the same time” was this scary.

FuruiNext time, approval. A claim moves from draft to submitted to approved to paid. What if someone could quietly edit an approved claim? Let's talk about state transitions.

References

The recordings were made on 31 October 2026 on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with Spring Boot 4.1.2 (Spring Framework 7.0.10, MyBatis 3.5.19, mybatis-spring-boot 4.1.0), OpenJDK 25.0.4 and PostgreSQL 16.15. The claims server and the database server are separate network spaces (network namespaces) inside one machine, about 0.04 ms apart. Each concurrency experiment was run three times and results vary every run (the photos are separate runs). The database connection pool is Spring Boot's default of 10. Every number and output in the text is a real value read from the commands' output at the time. The characters are fictional.

Old-School Backend, part 9 — Transactions and concurrent updates / 2026.10

← Back to all posts