A refund endpoint sums existing refunds, checks the total against the order amount, and inserts a new refund if there's room — all inside one @Transactional method. Two requests for the same order arrive at once and both succeed, over-refunding the order. Why didn't the transaction prevent it, and what actually fixes it?
This is write skew, not a lost update — neither transaction updated a row the other one also updated (each inserted a new refund row), so at the database's default isolation level (read committed, or even repeatable read) there's no write-write conflict for the engine to detect. Both transactions read the same committed total, both independently computed that their own refund fit, and both inserted — the invariant "sum of refunds for this order stays under the total" spans multiple rows and was never something a single row-level check or lock could catch on its own. The fix is either SERIALIZABLE with a retry loop (which detects the read-write dependency and aborts one), or explicitly locking the row that represents the invariant — SELECT ... FROM orders WHERE id = ? FOR UPDATE — so the second request's SUM sees the first request's just-inserted refund before deciding.