Capstone 3: The Core Business (Transactions, Inventory, Idempotency)
The previous 44 articles handed you every part: the container, proxies, transactions, caching, messaging, scheduled jobs. This one has a single job — fit them into the right slots along the checkout path so that when 100 people grab 5 units at once you still get no oversell, no lost order and no notification sent too early. The hard part of a core business implementation is never "I cannot write the code"; it is "the boundary sits in the wrong place": which actions must succeed or fail together? Which number must never be cached? Which message must never leave before commit? Every answer here is something you can run yourself.
Five words, one line each (they recur throughout):
- Transaction: a bundle of database operations that either all take effect or all get undone, declared with the
@Transactionalannotation - Idempotency key: a unique number the client generates for "one checkout intent" — replay the same key ten times and you still get one order
- Optimistic lock / conditional update: no explicit lock; instead the
WHEREof theUPDATEcarries the precondition "there is still enough stock". If the precondition fails, the statement changes zero rows - Row lock: a sign hung on one database row while it is being written; writers on that row queue up, and the sign only comes down when the transaction ends
- Oversell: selling 6 units when only 5 exist — always caused by a time gap between "the number I read" and "the number actually deducted", during which somebody else changed it
grabbing concert tickets. Two people must not both lock the same seat, and the fix is not "let me peek at what is left" — it is "slam this seat into the system right now". Between those two acts there can be no time gap, otherwise the remaining-seats figure you read is forever stale. BeeOrder's whole anti-oversell design is exactly that sentence: merge "is there still stock" and "deduct it" into one SQL statement (UPDATE ... WHERE available >= n), leaving nobody a gap to cut in. The optimistic lock is a turnstile (one person at a time, whoever lands first wins); the pessimistic FOR UPDATE is walking off with the key to the whole box office (everyone else stands outside until you finish).
the glass wall of a revolving restaurant. While your transaction is open you occupy a table and a connection (one slot in the pool); until the bill settles (no commit), the queue outside just watches. So calling a remote API inside a transaction is like dragging the delivery courier in to sit at your table while you wait for him to pick up the food — the table goes from occupied 5ms to occupied 200ms, and at peak the entire restaurant (the pool) stops taking guests. That single image is where every trade-off in Section 3 comes from.

Those six branches map onto the six main threads of this article: the checkout path, the transaction boundary, concurrency and stock, the three idempotency points, async and compensation, and the caching trade-off. Each branch can fail on its own and be verified on its own — which is why this is the "closing" article of the series: it introduces no new component, it only tests whether you can put the existing ones in the right place.

This timeline is the single most important picture here, and every later section unpacks one of its steps. Hold on to one conclusion first: the first three steps are outside the transaction, the middle three share one commit, and the last one must come after the commit.
After this article you should be able to answer three questions:
- Why does "check whether stock is left, then decide whether to deduct" always risk oversell, while writing the same logic as one SQL statement does not?
- Why does
@Transactionalsometimes look like it did nothing at all — no error, no rollback? What are the three common postures that silently kill it? - At which exact instant should the "order placed" notification go out? What breaks if it is one step early, and what breaks if it is one step late?
The previous two articles fixed the tables and the contract; this one writes the code that actually runs. BeeOrder's layering discipline is one sentence: the Controller only adapts protocols, the Service owns business logic and transactions, the Mapper only touches SQL. Mix these up and transactions go out of control — the root of every trap that follows.
| Layer | Package | Responsibility | Never does |
|---|---|---|---|
| Controller | com.beeorder.order.controller | read params, call the service, wrap in Result | no business branches, no Mapper, no transactions |
| Service | com.beeorder.order.service | orchestration, transaction boundaries, idempotency and state transitions | no SQL assembly, never returns an Entity upward |
| Mapper | com.beeorder.order.mapper | a single SQL statement; conditional updates and unique constraints are enforced here | no business decisions, no multi-query orchestration |
This delivery lands in these classes: order.controller.OrderController, order.service.OrderService, order.mapper.OrderMapper / OrderItemMapper, inventory.service.InventoryService, inventory.mapper.InventoryMapper, payment.service.PaymentService. Cross-module calls follow the "order calls inventory, one-way" boundary — exactly where the available / locked / version fields fixed in the previous article get used.
The sandbox above shows a real risk: OrderService needs InventoryService to deduct stock, but if InventoryService injects OrderService back (say, to look up an order), constructor injection reports a circular dependency at startup. Boundary discipline is not a slogan; it decides whether your project even boots.
Checkout is the most critical and the most error-prone path in the project. See the whole picture first, then dig into the code:

The idempotency key is the identity card of "one checkout intent". Per the previous article's contract it lives in a header, not the body — so even if the body is replayed the key cannot be tampered with, and a gateway can handle it uniformly:
package com.beeorder.order.controller;@RestController@RequestMapping("/api/v1/orders")public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; // constructor injection, immutable dependency } @PostMapping public Result<OrderVO> create(@RequestHeader("X-Idempotency-Key") String idempotentKey, @Valid @RequestBody CreateOrderRequest req) { Long userId = JwtUtil.currentUserId(); // from the JWT, never trust a client userId req.setIdempotentKey(idempotentKey); // the key always comes from the header return Result.ok(orderService.create(userId, req)); }}This is the one method in the article to read line by line. It chains "validate -> compute -> persist -> deduct stock -> write ledger" into one transaction:
package com.beeorder.order.service;@Servicepublic class OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final ProductMapper productMapper; private final InventoryService inventoryService; public OrderService(OrderMapper orderMapper, OrderItemMapper orderItemMapper, ProductMapper productMapper, InventoryService inventoryService) { this.orderMapper = orderMapper; this.orderItemMapper = orderItemMapper; this.productMapper = productMapper; this.inventoryService = inventoryService; } @Transactional(rollbackFor = Exception.class) // see Section 3: roll back on any exception public OrderVO create(Long userId, CreateOrderRequest req) { // 1) Idempotency fast path: a hit returns the first result immediately Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey()); if (exist != null) { return assembleVO(exist); } // 2) Validate products and compute the amount (BigDecimal, accumulated per item) BigDecimal total = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (CreateOrderRequest.Item i : req.getItems()) { Product p = productMapper.selectOnShelf(i.getProductId()); if (p == null) { throw new BizException(ErrorCode.ORDER_STATUS_ILLEGAL, "product missing or delisted"); } BigDecimal amount = p.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())); total = total.add(amount); items.add(OrderItem.snapshotOf(p, i.getQuantity(), amount)); // price snapshot } // 3) Persist the order (status CREATED) plus its items Order order = Order.created(userId, req, total); orderMapper.insert(order); // unique key uk_order_idem guards duplicates items.forEach(it -> it.setOrderId(order.getId())); orderItemMapper.batchInsert(items); // 4) Deduct stock (same transaction, conditional update) and write the ledger for (OrderItem it : items) { inventoryService.lock(it.getProductId(), it.getQuantity(), order.getId()); } return assembleVO(order); }}Step by step:
- Step 1, idempotency: query
(user_id, idempotent_key); a hit means this order was already placed, so return the first result and skip the insert conflict — this is the fast path, with the unique index as the real backstop. - Step 2, validation and math:
selectOnShelfreturns only listed products; the amount accumulates withBigDecimal.multiply, not a singledoubleallowed. - Step 3, persistence: insert the order first to obtain its
id, then batch-insert items carryingorder_id;OrderItem.snapshotOffreezes the name and price snapshots. - Step 4, stock: delegated to
InventoryService.lock, which uses a conditional update and the affected-row count to decide success.
note that the main flow has no Redis and no message sending. Those are operations a transaction cannot tolerate; Section 5 explains why they must move out.
"Create the order + deduct stock + write the ledger" is one set of atomic facts: an order with stock not deducted oversells; stock deducted with no order loses goods. They must share the same transaction — all succeed or all roll back.
@Transactional( rollbackFor = Exception.class, // roll back even on checked exceptions timeout = 3, // 3-second cap, so slow queries do not hold locks isolation = Isolation.READ_COMMITTED)public OrderVO create(Long userId, CreateOrderRequest req) { ... }Why write rollbackFor = Exception.class? Spring rolls back by default only on RuntimeException and Error. If your code throws a checked exception (say an IOException wrapped by a query), without this option the transaction commits anyway — the order is created but stock is not deducted. Writing Exception.class explicitly is insurance you will regret skipping.
Transaction granularity also needs a boundary; the test is "if this fails, must everything before it be undone?":
| Inside the transaction | Outside it (after commit) |
|---|---|
| insert order and items | send the order-placed notification |
| deduct stock and write the ledger | write audit logs, report metrics |
| update order status | call a remote service, publish to MQ |
Why must remote calls and messages move out? They are not database operations but they hold database connections and row locks for a long time. A 200ms SMS endpoint stuck inside a transaction means that inventory row's lock is held 200ms longer, which under load becomes lock waits and a drained connection pool. The rule: persist inside the transaction, publish after it commits.
This sandbox sets the two outcomes side by side: switch to "outer rollback" and you see the order row and the ledger entry disappear together — atomicity made visible. For notifications, the cleaner route is events: publish an event inside the transaction and let a listener run it only after commit via @TransactionalEventListener(phase = AFTER_COMMIT) (covered in #41), guaranteeing "not too early" and "holds no locks".
@Transactional is silently ignored on private methods or on a self-call like this.xxx() — Spring intercepts through a proxy, and a self-call never goes through it. In Section 4 inventoryService.lock works because it is a cross-bean call; write it as this.lockStock() and the annotation is ignored, so the stock deduction will not roll back with the order.
Inventory is the only data in the project that a high-concurrency stampede fights over. Three requests buy the last unit at once; handled poorly, all three succeed — that is oversell. Three mainstream options, compared first:
| Option | Principle | Pros | Cons | Scale |
|---|---|---|---|---|
| A pessimistic lock | SELECT ... FOR UPDATE, then update | intuitive, strongly consistent | long lock hold, poor concurrency, deadlock-prone | low concurrency, strong consistency |
| B optimistic lock (conditional update) | atomic UPDATE ... WHERE available >= n | no explicit lock, good concurrency, simple | higher failure rate under high contention | most e-commerce (recommended) |
| C Redis pre-deduction | atomic Lua decrement in memory, async persist | highest throughput, absorbs traffic | weak consistency, data loss on crash, needs compensation | flash-sale scale |
-- must be inside a transaction; FOR UPDATE holds the row lock until the transaction endsSELECT available, locked FROM inventory WHERE product_id = #{productId} FOR UPDATE;-- the application checks available >= n, then runs the UPDATEUPDATE inventory SET available = available - #{n}, locked = locked + #{n} WHERE product_id = #{productId};If the lock does not use an index (the WHERE column is unindexed) it escalates to a full-table lock — the single most deadlock-prone pattern there is.
The idea is to merge "check" and "deduct" into one atomic SQL, letting the database validate at the instant it takes the row lock:
<update id="lockStock"> UPDATE inventory SET available = available - #{quantity}, <!-- sellable decreases --> locked = locked + #{quantity}, <!-- locked increases --> version = version + 1, update_time = NOW(3) WHERE product_id = #{productId} AND available >= #{quantity} <!-- the anti-oversell clause --></update>Long productId, int quantity; // passed in by the callerint rows = inventoryMapper.lockStock(productId, quantity);if (rows == 0) { // zero affected rows = condition missed throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); // 1001 insufficient stock}Why is available >= #{quantity} the answer to oversell? Because inside the database this UPDATE takes an exclusive lock on the row, reads, checks and writes in one breath: with stock 5 and three requests buying 2 each, only one succeeds; the other two run when available is already short, the WHERE fails, they affect 0 rows and throw 1001. There is no time window between check and deduct, so oversell is physically impossible.
Then is the version column still needed? Yes, but it solves a different problem: when you must SELECT, compute in the application, then write back (say "if stock is short, trigger restock logic"), AND version = #{v} detects whether anyone changed the row meanwhile. For a pure decrement, available >= n is already enough.
Put stock in Redis and use a Lua script to make "check + deduct" atomic:
-- KEYS[1] = stock:{productId}, ARGV[1] = quantitylocal stock = tonumber(redis.call('GET', KEYS[1]))if stock == nil or stock < tonumber(ARGV[1]) then return -1 -- insufficient stockendredis.call('DECRBY', KEYS[1], tonumber(ARGV[1]))return stock - tonumber(ARGV[1]) -- remaining stockIts value is keeping traffic away from the database; the cost is a second source of truth: what if Redis deducts but the async write fails? What if Redis crashes and the pre-deduction is lost? These need reconciliation and compensation — worth it only at flash-sale volume where the database truly cannot cope.

optimistic locking fails in exactly one scenario — extremely high contention on a single row (say 1000 QPS fighting over one item). Then many requests get 0 affected rows, and retrying is useless (there really is no stock). The fix is rate limiting or queueing, not Redis — the bottleneck is not the lock but the scarce resource itself.
The main flow in Section 2 has an "idempotency pre-check", but that query cannot be the only line of defence: under concurrency two requests may both find nothing and both insert. The real backstop is the unique index designed last article, uk_order_idem (user_id, idempotent_key).
try { orderMapper.insert(order);} catch (DuplicateKeyException e) { // unique-key conflict = a concurrent duplicate submit: fetch and return the first result Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey()); log.info("duplicate checkout, returning first result orderNo={}", exist.getOrderNo()); return assembleVO(exist);}On conflict, do not error out — return the first result; to the user, two clicks yield the same order, and that is idempotency. The three idempotency points reuse the previous article's design, unchanged:
| Scenario | Idempotency key | Constraint |
|---|---|---|
| Checkout | order.idempotent_key (client UUID) | unique (user_id, idempotent_key) |
| Payment callback | payment.trade_no (gateway number) | unique uk_payment_trade_no + status check |
| Stock deduction | inventory_log (ref_id, type) | unique uk_invlog_ref_type |
catching DuplicateKeyException inside a @Transactional method needs care — under some drivers/configurations a unique-key conflict marks the current transaction rollback-only, so even after you return the first result, the commit phase throws UnexpectedRollbackException. The safe pattern is to extract "insert order" into its own transaction method (REQUIRES_NEW, or a separate service) and catch the conflict outside it, so the outer transaction is not poisoned.
What that snippet actually does under concurrency deserves an animation: even if both requests clear the pre-check at the same time, only one insert can survive — the other is stopped on the spot by the unique index, then picked back up by the code and answered with the first result:

The payment callback is the only publicly exposed write endpoint, and the largest attack surface. It skips JWT but must do three things: verify the signature, be idempotent, and honour the state machine.
package com.beeorder.payment.controller;@RestController@RequestMapping("/api/v1/payments")public class PaymentController { private final PaymentService paymentService; public PaymentController(PaymentService paymentService) { this.paymentService = paymentService; } /** gateway async callback: no JWT, but signature + idempotency required */ @PostMapping("/callback") public Result<Void> callback(@RequestBody PayCallbackDTO dto) { paymentService.verifySign(dto); // 1) verify signature/amount, else throw 2002 paymentService.handleCallback(dto); // 2) idempotency + state machine return Result.ok(null); // 3) fixed success ack so the gateway stops resending }}PaymentService.handleCallback is the two lines of defence from the previous article, made real:
@Transactional(rollbackFor = Exception.class)public void handleCallback(PayCallbackDTO dto) { Payment pay = paymentMapper.selectByTradeNo(dto.getTradeNo()); if (pay != null && PayStatus.SUCCESS.name().equals(pay.getStatus())) { log.info("duplicate payment callback, ignored tradeNo={}", dto.getTradeNo()); return; // defence 1: already succeeded, idempotent return } // defence 2: conditional update, only CREATED may move to PAID int rows = orderMapper.updateStatus(dto.getOrderId(), OrderStatus.CREATED, OrderStatus.PAID); if (rows == 0) { log.warn("order status does not allow payment, ignoring orderId={}", dto.getOrderId()); return; // closed or already paid, ignore } paymentMapper.markSuccess(dto.getTradeNo()); // unique trade_no as backstop inventoryService.commitLocked(dto.getOrderId()); // convert locked stock to deducted}Where each check lives matters: the signature in the Controller (rejecting forged requests), idempotency at the start of the Service (rejecting repeated callbacks), the state machine inside the conditional update (rejecting "a closed order receiving a payment"). Remove any one and dirty data leaks in from a different direction.
the most effective way to verify this logic is not reading it but writing a duplicate-callback integration test — send the same trade_no twice, then assert the order becomes PAID once, stock is deducted once, and the second call returns success with code 2001.
"Auto-close and release stock 30 minutes after creation when unpaid" is a system behavior, implemented by a scheduled job:
@Componentpublic class OrderCloseJob { @Scheduled(fixedDelay = 60_000) // scan once a minute public void closeExpired() { if (!redisLock.tryLock("beeorder:job:close-order", 55, TimeUnit.SECONDS)) { return; // only one instance wins the lock } try { // scan CREATED orders past expire_time (via idx_order_status_expire) List<Long> ids = orderMapper.selectExpiredIds( OrderStatus.CREATED, LocalDateTime.now(ZoneOffset.UTC), 200); ids.forEach(orderService::close); } finally { redisLock.unlock("beeorder:job:close-order"); } }}@Transactional(rollbackFor = Exception.class)public void close(Long orderId) { // conditional update: only CREATED may close, naturally safe against a racing payment callback int rows = orderMapper.updateStatus(orderId, OrderStatus.CREATED, OrderStatus.CLOSED); if (rows == 0) { return; // already paid or closed, skip } for (OrderItem it : orderItemMapper.selectByOrderId(orderId)) { inventoryService.release(it.getProductId(), it.getQuantity(), orderId); // locked -> available }}Two things must be clear. First, closing is not "query then update" but a conditional update WHERE status = 'CREATED'; so even if the payment callback and the close job reach the same order at once, only one succeeds and the other affects 0 rows, avoiding "closed but then paid". Second, multiple instances must dedup: @Scheduled runs on every instance, so a Redis distributed lock (#40's setIfAbsent plus expiry) ensures only one scans at a time — otherwise two instances each close the same batch and each restores stock.
release also writes a ledger entry (type = 'RELEASE'), and the unique uk_invlog_ref_type guarantees "one release per order" — retries or duplicate runs never restore stock twice.
The discipline "every status change must carry the state it came from" is best seen as a state machine of an order's life — each outgoing edge carries its own admission condition. Click each state to see its exits, and watch the two edges from CREATED: the payment callback and the close job are competing for this order's single chance to leave CREATED at all:
The list endpoint uses conditional paging (OrderQuery -> PageResult), with SQL riding the idx_order_user_status_time composite index; the detail endpoint reads the item snapshot fields directly, never joining product — the payoff of the "snapshot" idea from the first article:
<!-- my order list: conditional paging, all params follow the leftmost prefix --><select id="pageByUser" resultType="com.beeorder.order.vo.OrderVO"> SELECT o.order_no, o.status, o.total_amount, o.create_time, o.pay_time FROM `order` o WHERE o.user_id = #{userId} AND o.deleted = 0 <if test="status != null"> AND o.status = #{status} </if> <if test="beginTime != null"> AND o.create_time >= #{beginTime}</if> <if test="endTime != null"> AND o.create_time < #{endTime} </if> ORDER BY o.create_time DESC LIMIT #{size} OFFSET #{offset}</select><!-- order detail: items read snapshots directly, renames and repricing do not touch history --><select id="selectItemsByOrderId" resultType="com.beeorder.order.vo.OrderItemVO"> SELECT id, product_id AS productId, product_name AS productName, <!-- the name at checkout --> product_price AS productPrice, <!-- the price at checkout --> quantity, amount FROM order_item WHERE order_id = #{orderId}</select>- the list SQL's first condition is
user_id, hitting the composite index's leftmost column; status and time continue to the right - the detail SQL has no
JOIN product— the name and price are snapshots, read fromorder_item - remember the back-quotes around
order: it is a SQL keyword, and omitting them is error 1064
Tip: user_id must come from the JWT (JwtUtil.currentUserId()), never from a request parameter. Otherwise a single ?userId=someone-else exposes their orders — the most classic and the most fatal authorization hole.
The caching rule is one sentence: only read-heavy, write-light data tolerant of brief inconsistency deserves a cache. Product detail fits perfectly — almost purely read, occasionally edited:
@Servicepublic class ProductService { /** product detail: read-heavy, cached 10 minutes; the cache holds a VO, not an Entity */ @Cacheable(cacheNames = "product:detail", key = "#id", sync = true) public ProductVO detail(Long id) { Product p = productMapper.selectOnShelf(id); return p == null ? null : ProductConverter.toVO(p); } /** product edit: write the database, then evict the cache to avoid stale reads */ @CacheEvict(cacheNames = "product:detail", key = "#id") public void update(Long id, ProductUpdateDTO dto) { productMapper.update(id, dto); }}Inventory is the opposite and must not be cached this way. It is a write hotspot; caching stock manufactures a "cache vs database inconsistency" problem: deduction uses a conditional update, and the cached number lags behind at any moment. BeeOrder's boundary:
| Data | Cacheable? | Reason |
|---|---|---|
| Product detail (name, price) | yes, TTL 10 minutes | read-heavy, brief staleness acceptable |
| Inventory quantity | no (except the Redis pre-deduction case) | write hotspot; a cache inevitably fights the conditional update |
| Order list/detail | no | strongly consistent, per-user isolated, low hit rate |
when caching a list or an object with sensitive fields, do not forget that the key must include every parameter that affects the result (userId, status, page). Otherwise users read each other's caches — a cache-induced authorization hole, more dangerous than no cache at all.
No design is proven until a concurrency test proves it. The trick is a CountDownLatch firing 100 threads at the same instant to maximize contention:
@Testvoid concurrentCheckoutShouldNotOversell() throws Exception { int stock = 5, threads = 100; inventoryMapper.seed(productId, stock); // initialize stock = 5 CountDownLatch ready = new CountDownLatch(threads); CountDownLatch start = new CountDownLatch(1); AtomicInteger success = new AtomicInteger(); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { final int seq = i; pool.submit(() -> { ready.countDown(); start.await(); // wait for the start gun try { orderService.create(userId, request(seq, 1)); // each with its own idempotency key success.incrementAndGet(); } catch (BizException e) { // 1001 insufficient stock, expected assertEquals(ErrorCode.STOCK_NOT_ENOUGH.getCode(), e.getCode()); } return null; }); } ready.await(); // all 100 threads ready start.countDown(); // fire: charge together pool.shutdown(); assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS)); assertEquals(stock, success.get()); // exactly 5 succeed assertEquals(0, inventoryMapper.selectAvailable(productId)); // stock precisely zero assertEquals(stock, orderMapper.countByUser(userId)); // orders = stock}- every thread uses an independent
idempotentKey, so this tests oversell, not idempotency; the two get separate tests - three assertions: successes = stock, remaining stock = 0, orders = stock — any one failing means oversell or under-deduction
- run this against a real database (not H2 in-memory), or row-lock and isolation behaviors will not match
Key point: the value of a concurrency test is that it turns an occasional bug into a reproducible one. A single run may pass by luck; repeat it (or raise the thread count) until it is stable — exactly the "repeatable integration test" the delivery article expands on.
| Symptom | Root cause | Fix |
|---|---|---|
| stock deducted, order rolled back | @Transactional ignored on a self-call/private method; no proxy | deduct via a cross-bean call, or inject the self-proxy; the transactional method must be public |
| negative stock / oversell | the deduction SQL omits WHERE available >= n | the conditional update is the only defence — always write it; the unsigned column adds a second layer |
| heavy deadlocks under load | multiple inventory rows locked in different orders, locks too wide | lock batched deductions by ascending product_id; keep WHERE on a unique index to avoid table locks |
The third trap deserves detail: one order contains products A and B, another contains B and A; if both lock in cart order, they wait on each other's row locks and deadlock. Locking uniformly by ascending product_id breaks the cycle.
The layering in Section 1 is not decoration — it decides whether an object changes costume at each layer and where the transaction boundary lands. The lab below is one top-down checkout call; switching its arguments shows you three separate things: whether the dependency direction may be reversed, what shape the same data has in the Controller / Service / Mapper layers (DTO, Entity, VO), and which method in which layer actually carries the transaction.
The "cost of skipping a layer" option demonstrates a very concrete crime: a Controller calling the Mapper directly, so that SQL runs outside any transaction and a stock deduction never rolls back with the order. That is trap number one in Section 11's table, reproduced live.
Now widen the frame from "one method call" to "one HTTP request crossing the whole site". DispatcherServlet (#22), validation and exception resolvers (#25), filters and interceptors (#26), the connection pool (#28) and this article's transaction are five workstations on one assembly line:
Together these two labs give the article's first main thread: structurally, calls may only go downward (layer); at runtime, there is one fixed pipeline (apiflow). Every trap ahead is really "doing the wrong thing at one station on that line" — waiting for a remote API inside a transaction, or publishing a message before commit.
Another way to learn it: turn this article's conclusions into a handful of commands and tap them out in the inner-kernel console — watching a rollback happen, the unique index catch a duplicate, and a rate limiter get chosen sticks far better than a second read:
Section 4 listed three anti-oversell options and said BeeOrder uses B (the conditional update). This section fills in how C (Redis pre-deduction) actually works, because it is the only one that moves check-and-deduct out of the database altogether: a Lua script (a block of code executed atomically inside Redis) fuses "read the remainder → is it enough → deduct" into one uninterruptible operation.
Its payoff is that the client never sees an intermediate state: there is no "the number I just read", so oversell is physically impossible. The price is that this "remainder" now lives in memory rather than in the database — the second source of truth mentioned at the end of Section 4. Crashes, failed async persistence, non-atomicity across keys: all of it has to be caught by reconciliation and compensation.
So when concurrency really arrives, which dial do you turn first? This sandbox pairs "concurrency" with "Redis pre-deduction on or off" and watches three readouts at once: consistency, P99, and active database connections.
Successful orders: 5, the other 295 return code 1001Remaining stock: 0 (still no oversell)P99: 1876ms ↑↑DB active connections: 10/10 — Connection is not available, request timed out after 30000ms# consistency held; queueing did not: 300 requests fighting one row lock over 10 connections
the sandbox conclusion is worth memorising — no oversell does not mean no problem. Judge on the same three numbers every time: successes equal to stock, P99 under control, pool not pinned. If any one of them is off, start rate-limiting; do not rush to swap MySQL for Redis.
Section 3 stated the discipline "remote calls and messages must leave the transaction", but a discipline needs a place to be verified by hand. Two pieces cooperate here: Spring events (#41 — publish inside the transaction, listeners run synchronously on the publisher's thread) plus @TransactionalEventListener(phase = AFTER_COMMIT) — a phase listener, i.e. a listener that is only woken up if the transaction genuinely committed.
// ① inside the transaction: only publish, touch nothing externalapplicationEventPublisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getOrderNo()));// ② after commit: the listener wakes, and the message it sends can always be found in the DB@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)public void onOrderCreated(OrderCreatedEvent e) { mqProducer.send("order.created", e.orderNo()); // MQ / SMS / loyalty points}坑:writing plain @EventListener instead of @TransactionalEventListener sends the notification before the commit. The user gets an "order placed" SMS, opens the detail page and sees "order not found" — that race window is usually a few milliseconds, which makes it near-certain in a test environment and nearly impossible to catch in production logs. The cache refill in Section 9 sits on the same timeline; do not move it earlier either.
The three labs below each unpack one of these uncertainties. The first cures "did my @Transactional even take effect" — all seven propagation types (REQUIRED / REQUIRES_NEW / NESTED / SUPPORTS / NOT_SUPPORTED / NEVER / MANDATORY) laid out together, plus the rollback-rule option:
The second shows exactly what happens when a message leaves before the commit, and how a phase listener changes it:
The third returns to Section 9's caching boundary — why product detail is safe to cache and stock is not:

That figure merges the two timelines people most often mis-debug: the correct cache order is "write the database, then evict", and the correct message order is "commit, then publish". Both obey one rule of thumb — anything the outside world can see (a cached value, an MQ message, an SMS on someone's phone) must come after COMMIT.

End Section 4 with this comparison: what pessimistic locking and Redis pre-deduction each solve, what new trouble each brings, and the project's own choice on the right. The only line that matters is the footnote — is there a time window between check and deduct; that single difference decides whether oversell is possible.
By now every mechanism in this article has walked on stage. A quick test: which bad thing does each one actually stop? Click a mechanism on the left, then the failure it is responsible for on the right — memorise these pairings and your first reflex at 3 a.m. will be a very different one:
Copy every snippet below straight into a search box — do not paraphrase or shorten it. These are the walls beginners actually hit in the core business layer.
| Error text (fragment) | What really happened | 30-second fix | Read more in |
|---|---|---|---|
Duplicate entry '1001-7f3a...' for key 'order.uk_order_idem' | Duplicate idempotency key: a client replay, a double click, or a retry reusing the same key | This is not a bug — it is the unique index doing its job: catch DuplicateKeyException, look up the first result and return it instead of throwing a 500 | Section 5 · #44 unique-key design |
Transaction timed out: deadline was ... (with @Transactional(timeout = 3)) | A long transaction: a remote call, an SMS, an HTTP request or a ten-thousand-row scan parked inside it | Find the slowest step in the trace; move every non-DB action into an AFTER_COMMIT listener and leave only SQL | Section 3 · #31 transaction internals |
org.springframework.dao.CannotAcquireLockException / Deadlock found when trying to get lock | Two transactions locked several inventory rows in different orders and are waiting on each other | Lock batched deductions by ascending product_id; make sure WHERE uses a unique index instead of scanning the table | Section 11 |
Connection is not available, request timed out after 30000ms (HikariPool-1) | Connections are pinned by long transactions: with the default maximumPoolSize=10, ten slow requests make the eleventh queue | First check for a remote call inside the transaction; then check whether spring.datasource.hikari.maximum-pool-size is still the local default | Section 13 sandbox · #28 connection pools |
A method annotated @Transactional throws, yet the rows stay written (nothing rolled back) | Self-invocation: this.lockStock() never passes the proxy; or the annotation sits on a private / final method; or you threw a checked exception without rollbackFor | Three checks: is the method public, does the call cross beans, was it a RuntimeException. Where needed, inject your own proxy (@Lazy self-injection) | Section 3 · #14 AOP internals |
| The user receives "order placed" but the detail page answers "not found" | The message left before the commit (@EventListener instead of @TransactionalEventListener(phase = AFTER_COMMIT)), so the consumer outran the database | Switch to the phase listener; if you truly must send immediately, use a local outbox table plus scheduled delivery for at-least-once | Section 14 · #41 events and MQ |
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only | An inner @Transactional threw and marked the transaction rollback-only; the outer code caught the exception and tried to commit anyway — the mark cannot be erased | Do not let the inner call reuse the outer transaction: switch it to REQUIRES_NEW, or extract it into its own service method with its own transaction | Section 14 txprop lab · #31 |
| After editing a price the list page keeps showing the old one; or a user reads somebody else's cache | Cache/database inconsistency: the write skipped @CacheEvict; or the cache key omits a parameter that changes the result, such as userId | The key must include every parameter affecting the output; write paths always "persist, then evict"; if in doubt drop the TTL to 30s and observe | Section 9 · #32 Redis and caching |
three of those eight (the idempotency conflict, the missing AFTER_COMMIT, the cache isolation leak) never appear as a red exception — the program happily keeps running with wrong data. So acceptance for the core layer cannot be "did anything throw"; run the concurrency test from Section 10 and walk the exercises in Section 17 line by line.
The "explodes only at the finish line" error in row 7 deserves a scene of its own: it is the one failure in this article whose business log looks perfectly healthy — the outer method caught the exception and even logged it, yet the commit phase kills the request. Do not read the answer first; click the frame you think is the culprit:
Checkout intermittently returns 500 while the data looks 'fine': no order row, no stock deducted. The log shows the outer code caught the exception and printed 'duplicate checkout, returning first result' — and the client still got a 500.
A warm-up question, straight from Section 4's single line of defence:
Now a combined question threading Sections 3, 9 and 14:
Goal: run a minimal but complete concurrency proof — how the conditional update closes the oversell door, and how an affected-row count becomes a business error code. All you need is a JDK, Maven and a free MySQL 8 image.
Step one, start a real MySQL (not H2 — row-lock behaviour will not match):
docker run -d --name bee-mysql -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=bee -e MYSQL_DATABASE=beeorder mysql:8.0Step two, create the smallest possible stock table and seed 3 units (sql/step1.sql):
CREATE TABLE inventory ( product_id BIGINT NOT NULL PRIMARY KEY, available INT UNSIGNED NOT NULL, -- UNSIGNED makes negatives a second safety net version INT NOT NULL DEFAULT 0);INSERT INTO inventory (product_id, available) VALUES (2001, 3);Before step three, generate the pom.xml — tick exactly the dependencies this level needs, each with a one-line reason for existing; when Level 3 adds the Redis dedup lock, come back and tick Redis:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Step three, write the main class (pom.xml needs only spring-boot-starter-jdbc and mysql-connector-j):
package com.example.stock;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.jdbc.core.JdbcTemplate;import java.util.concurrent.*;import java.util.concurrent.atomic.AtomicInteger;@SpringBootApplicationpublic class StockApp { public static void main(String[] args) throws Exception { var ctx = SpringApplication.run(StockApp.class, args); JdbcTemplate jdbc = ctx.getBean(JdbcTemplate.class); int threads = 10; // 10 concurrent requests for 3 units var ready = new CountDownLatch(threads); var gun = new CountDownLatch(1); // the starting gun: real concurrency starts here var ok = new AtomicInteger(); var pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { ready.countDown(); gun.await(); // the key: check and deduct live in one statement int rows = jdbc.update( "UPDATE inventory SET available = available - 1, version = version + 1 " + " WHERE product_id = 2001 AND available >= 1"); if (rows == 1) ok.incrementAndGet(); // won a unit else System.out.println("insufficient stock, order rejected"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } ready.await(); gun.countDown(); pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); Integer remain = jdbc.queryForObject( "SELECT available FROM inventory WHERE product_id = 2001", Integer.class); System.out.println("success=" + ok.get() + " remaining=" + remain); ctx.close(); }}Step four, compile and run:
mvn -q compile exec:java -Dexec.mainClass=com.example.stock.StockAppExpected output — the interleaving and ordering of the rejection lines varies, but the final tally must not:
insufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedsuccess=3 remaining=0That last line must read success=3 remaining=0. Change the thread count to 200 and repeat it ten times: the answer is invariant. If it ever drifts, your SQL is missing AND available >= 1.
Acceptance checklist: ① say what ready and gun each guard; ② delete AND available >= 1 and rerun — watch the failure or the negative (the UNSIGNED column refuses it first, which is the second safety net working); ③ explain why no SELECT of the stock is needed anywhere in this program.
Change exactly one thing per run and the conclusion flips:
- Split the
UPDATEinto "read available withqueryForObject, test it in Java, thenupdate", leaving everything else alone. You will observesuccess=exceeding 3 and the stock going negative or being refused byUNSIGNED— that is the check-versus-act window, the real dividing line between options A/B in Section 4. Fixing it means addingFOR UPDATEand wrapping the block in a transaction, which incidentally recreates the connection-hogging scene from the Section 13 sandbox. - Move the deduction into a
@Transactionalservice method but call it viathis.deduct(...)from the same class, then throw aRuntimeExceptionafterwards. You will observe the order vanishing while the stock stays deducted — the self-call bypassed the proxy and the annotation became decoration. Inject a different bean and call through it, and consistency returns immediately. - Put a
Thread.sleep(200)inside the transactional method to imitate a remote call, setspring.datasource.hikari.maximum-pool-sizeto 4, and drive it with 20 threads. You will observeConnection is not available, request timed out after 30000mswhile the database itself is nearly idle — the bottleneck is connections held by transactions, not SQL. - Change the listener from
@TransactionalEventListener(phase = AFTER_COMMIT)to plain@EventListenerand query the order back with the sameJdbcTemplateinside it. You will observe occasional — often frequent —nullresults, because the listener runs before the commit.
Tip: after variant 2, reread row 5 of the Section 15 table; both tell the same story.
Add the "idempotency + ledger + post-commit notice" trio to the Level 1 project so the loop is genuinely deliverable.
Requirements:
- Give the order table an
idempotent_keyforming a unique index withuser_id; on conflict catchDuplicateKeyExceptionand return the first order number (never surface an error to the client) - Write one
inventory_logrow per deduction with a unique(ref_id, type)key, wheretypeis one ofLOCK/RELEASE/COMMIT - Implement closing as a conditional update
WHERE status = 'CREATED', driven by a@Scheduledjob scanning expired orders every minute, deduplicated across instances with a RedissetIfAbsentplus expiry - Route the "order placed" notification through
@TransactionalEventListener(phase = AFTER_COMMIT), printing only, to stand in for a real message - Provide one command:
mvn -q testrunning all three test layers (unit / slice / concurrent integration), green required
Acceptance checklist: ① fire 20 requests with the same idempotency key and find exactly 1 order row and 1 ledger row; ② 100 threads for 5 units yields exactly 5 successes and 0 remaining; ③ force the notification listener to throw and confirm the order stays PAID (a failed notice must not roll back the order); ④ flip AFTER_COMMIT back to @EventListener and show that the concurrency test now reproduces a "found null" — document that negative proof in the README as a team-wide red line.
from memory, say why "check + deduct" must live in a single SQL statement, and what the application should do when the affected-row count is 0.
name the three common postures in which @Transactional silently fails. Why does none of them raise an error?
which operations must never be put inside a transaction? Answer in terms of what resource they hold, not "it is bad practice".
why must the checkout notification hang on @TransactionalEventListener(phase = AFTER_COMMIT)? If that downstream action fails, should the order roll back?
product detail is cacheable and stock is not — on which dimension does the difference actually fall? If you ever do cache stock, what extra must you build first?
deduct in one statement, keep the network out of the transaction, publish only after commit, cache only what you can afford to see stale.
this article turned the "contract" into code that runs. The essence is four things: bind order, stock and ledger into one atomic operation with @Transactional(rollbackFor = Exception.class); make oversell physically impossible with UPDATE ... WHERE available >= n; push checkout and payment idempotency down to the database with unique indexes plus the state machine; and prove "100 requests for 5 units leave exactly 5 successes" with a CountDownLatch test. Do not forget three disciplines either: move remote calls and messages out of the transaction, guard scheduled jobs with a Redis lock against multi-instance reruns, and remember that self-invoked @Transactional silently fails. With that, BeeOrder's main path is closed — next article we give it unit tests, integration tests, CI/CD and containerized deployment so it can truly go live.