Capstone 3: The Core Business (Transactions, Inventory, Idempotency)

bee2026-10-0870 min read0 views
The full order flow: inventory check and deduction, order persistence, transaction boundaries, idempotency keys and optimistic locking against oversell, payment callbacks and timeout closing.
1 / 172
Section
0. The 30-second version
2 / 172

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.

3 / 172

Five words, one line each (they recur throughout):

4 / 172
  • Transaction: a bundle of database operations that either all take effect or all get undone, declared with the @Transactional annotation
  • 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 WHERE of the UPDATE carries 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
5 / 172
类比

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).

6 / 172
类比

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.

7 / 172
Diagram
Figure · The core business use-case map
Figure · The core business use-case map
8 / 172

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.

9 / 172
Animation
Animation · Six checkout steps: no notice before commit
Animation · Six checkout steps: no notice before commit
10 / 172

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.

11 / 172

After this article you should be able to answer three questions:

12 / 172
  1. 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?
  2. Why does @Transactional sometimes look like it did nothing at all — no error, no rollback? What are the three common postures that silently kill it?
  3. 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?
13 / 172
Section
1. Layered responsibilities: who does what
14 / 172

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.

15 / 172
Table
LayerPackageResponsibilityNever does
Controllercom.beeorder.order.controllerread params, call the service, wrap in Resultno business branches, no Mapper, no transactions
Servicecom.beeorder.order.serviceorchestration, transaction boundaries, idempotency and state transitionsno SQL assembly, never returns an Entity upward
Mappercom.beeorder.order.mappera single SQL statement; conditional updates and unique constraints are enforced hereno business decisions, no multi-query orchestration
16 / 172

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.

17 / 172
Kernel lab
TeaVMService 互相依赖时会发生什么idle
订单服务与库存服务互相注入——正是循环依赖现场
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
18 / 172

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.

19 / 172
Section
2. The checkout flow: from request to order number
20 / 172

Checkout is the most critical and the most error-prone path in the project. See the whole picture first, then dig into the code:

21 / 172
Diagram
Figure 1 · The order creation flow
Figure 1 · The order creation flow
22 / 172
Section
2.1 Controller: the idempotency key comes from a header
23 / 172

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:

24 / 172
java
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));    }}
25 / 172
Section
2.2 OrderService.create(): the core orchestration
26 / 172

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:

27 / 172
java
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);    }}
28 / 172

Step by step:

29 / 172
  • 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: selectOnShelf returns only listed products; the amount accumulates with BigDecimal.multiply, not a single double allowed.
  • Step 3, persistence: insert the order first to obtain its id, then batch-insert items carrying order_id; OrderItem.snapshotOf freezes 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.
30 / 172
Key point

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.

31 / 172
Section
3. Transaction boundaries: what must share one transaction
32 / 172

"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.

33 / 172
java
@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) { ... }
34 / 172

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.

35 / 172

Transaction granularity also needs a boundary; the test is "if this fails, must everything before it be undone?":

36 / 172
Table
Inside the transactionOutside it (after commit)
insert order and itemssend the order-placed notification
deduct stock and write the ledgerwrite audit logs, report metrics
update order statuscall a remote service, publish to MQ
37 / 172

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.

38 / 172
Kernel lab
TeaVM下单主流程的事务实验idle
切到「外层回滚」,看订单与流水的最终状态
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
39 / 172

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".

40 / 172
Trap

@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.

41 / 172
Section
4. Stock deduction and oversell prevention (three options)
42 / 172

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:

43 / 172
Table
OptionPrincipleProsConsScale
A pessimistic lockSELECT ... FOR UPDATE, then updateintuitive, strongly consistentlong lock hold, poor concurrency, deadlock-pronelow concurrency, strong consistency
B optimistic lock (conditional update)atomic UPDATE ... WHERE available >= nno explicit lock, good concurrency, simplehigher failure rate under high contentionmost e-commerce (recommended)
C Redis pre-deductionatomic Lua decrement in memory, async persisthighest throughput, absorbs trafficweak consistency, data loss on crash, needs compensationflash-sale scale
44 / 172
Section
4.1 Option A: pessimistic lock
45 / 172
sql
-- 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};
46 / 172

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.

47 / 172
Section
4.2 Option B: optimistic conditional update (BeeOrder's choice)
48 / 172

The idea is to merge "check" and "deduct" into one atomic SQL, letting the database validate at the instant it takes the row lock:

49 / 172
xml
<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>
50 / 172
java
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}
51 / 172

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.

52 / 172

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.

53 / 172
Section
4.3 Option C: Redis pre-deduction
54 / 172

Put stock in Redis and use a Lua script to make "check + deduct" atomic:

55 / 172
lua
-- 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 stock
56 / 172

Its 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.

57 / 172
Animation
Animation · Preventing oversell
Animation · Preventing oversell
58 / 172
Key point

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.

59 / 172
Section
5. Idempotency: the unique index as the backstop
60 / 172

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).

61 / 172
java
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);}
62 / 172

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:

63 / 172
Table
ScenarioIdempotency keyConstraint
Checkoutorder.idempotent_key (client UUID)unique (user_id, idempotent_key)
Payment callbackpayment.trade_no (gateway number)unique uk_payment_trade_no + status check
Stock deductioninventory_log (ref_id, type)unique uk_invlog_ref_type
64 / 172
Trap

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.

65 / 172

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:

66 / 172
Animation
Animation · Why a double-click still creates one order
Animation · Why a double-click still creates one order
67 / 172
Section
6. Payment callback: signature, idempotency, state machine
68 / 172

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.

69 / 172
java
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    }}
70 / 172

PaymentService.handleCallback is the two lines of defence from the previous article, made real:

71 / 172
java
@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}
72 / 172

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.

73 / 172
Tip

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.

74 / 172
Section
7. Timeout closing: a scheduled job and multi-instance dedup
75 / 172

"Auto-close and release stock 30 minutes after creation when unpaid" is a system behavior, implemented by a scheduled job:

76 / 172
java
@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");        }    }}
77 / 172
java
@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    }}
78 / 172

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.

79 / 172
Note

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.

80 / 172

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:

81 / 172
Diagram
CycleThe order state machine: every change says where it came from1 / 6
Walk the six states in order and watch CREATED's two outgoing edges — they share one adjudicator: the conditional update WHERE status = 'CREATED'
→
→
→
→
→
↻
CREATED
Order placed, waiting for payment; expire_time is set, and 30 quiet minutes put it on the close job's radar. Of its two outgoing edges, exactly one can ever succeed.
All clearConditional updates are not only for stock — every order status transition must name the state it came from in its WHERE clause.
82 / 172
Section
8. Order queries: paging and reading snapshots
83 / 172

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:

84 / 172
Code
Codexml
<!-- 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 &gt;= #{beginTime}</if>     <if test="endTime != null">   AND o.create_time &lt;  #{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>
Notes
  • 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 from order_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.

85 / 172
Section
9. Caching: product cache and the inventory boundary
86 / 172

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:

87 / 172
java
@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);    }}
88 / 172

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:

89 / 172
Table
DataCacheable?Reason
Product detail (name, price)yes, TTL 10 minutesread-heavy, brief staleness acceptable
Inventory quantityno (except the Redis pre-deduction case)write hotspot; a cache inevitably fights the conditional update
Order list/detailnostrongly consistent, per-user isolated, low hit rate
90 / 172
Trap

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.

91 / 172
Section
10. The key test: concurrent checkout without oversell
92 / 172

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:

93 / 172
Code
Codejava
@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}
Notes
  • 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.

94 / 172
Section
11. Three traps to avoid
95 / 172
Table
SymptomRoot causeFix
stock deducted, order rolled back@Transactional ignored on a self-call/private method; no proxydeduct via a cross-bean call, or inject the self-proxy; the transactional method must be public
negative stock / oversellthe deduction SQL omits WHERE available >= nthe conditional update is the only defence — always write it; the unsigned column adds a second layer
heavy deadlocks under loadmultiple inventory rows locked in different orders, locks too widelock batched deductions by ascending product_id; keep WHERE on a unique index to avoid table locks
96 / 172

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.

97 / 172
Section
12. Two labs: lay the layering and one request's whole journey side by side
98 / 172

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.

99 / 172
Kernel lab
TeaVMOne checkout, top to bottom: who calls whom, where the transaction landsidle
Start with 'an order, top-down' to confirm calls may only go downwards; switch to 'object shapes' to watch the three costume changes DTO→Entity→VO; finish with 'where the tx boundary sits' — it must sit on a public Service method, which is exactly the premise of Section 3
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
100 / 172

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.

101 / 172

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:

102 / 172
Kernel lab
TeaVMOne checkout request crossing the whole siteidle
Run 'happy path' first; then switch through 'validation fails', 'business error', 'database down' and 'slow request' — note at which workstation each failing path gets intercepted, and which error message from the quick-reference in Section 15 each one matches
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
103 / 172

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.

104 / 172

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:

105 / 172
Console
106 / 172
Section
13. The third way to fight contention, and "should I rate-limit or switch plans?"
107 / 172

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.

108 / 172

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.

109 / 172

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.

110 / 172
Sandbox
SandboxOversell sandbox: which dial to turn when the burst lands
Result
Successful orders: 5, the other 295 return code 1001
Remaining 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 most misdiagnosed scene there is: nobody oversold, so people assume it is fine, while the pool is pinned at its ceiling. What you need now is rate limiting or queueing, not a different locking plan — the bottleneck was never the lock but the scarce stock and the finite connections.
111 / 172
提示

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.

112 / 172
Section
14. Propagation and "publish only after commit": three closing labs
113 / 172

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.

114 / 172
Code
Codejava
// ① 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}
Notes

坑: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.

115 / 172

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:

116 / 172
Kernel lab
TeaVMSeven propagation types: is this the same bill or a separate one?idle
Start with the REQUIRED default (checkout and deduction share one transaction); switch to REQUIRES_NEW to see why an audit log commits independently and survives the order rolling back; finish on the rollback rules to confirm that checked exceptions do not roll back by default — precisely why Section 3 insists on an explicit rollbackFor = Exception.class
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
117 / 172

The second shows exactly what happens when a message leaves before the commit, and how a phase listener changes it:

118 / 172
Kernel lab
TeaVMEvent timing: who speaks before the commitidle
Cycle through 'publish is sync', 'AFTER_COMMIT timing' and 'hand off to MQ' against the snippet above: events themselves are synchronous — only the phase makes a listener wait for the commit
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
119 / 172

The third returns to Section 9's caching boundary — why product detail is safe to cache and stock is not:

120 / 172
Kernel lab
TeaVMThe three cache traps: penetration, breakdown, avalancheidle
Use 'miss then fill' to see which step does the refill; then 'penetration' (a nonexistent id hammering the database) and 'breakdown & avalanche' (a hot key expiring and sending everything back to source at once), against Section 9's cacheable-or-not table — stock stays out of the cache precisely because it cannot survive this timeline
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
121 / 172
Diagram
Figure · Cache fill and publish-after-commit
Figure · Cache fill and publish-after-commit
122 / 172

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.

123 / 172
Diagram
Figure · Choosing among the three anti-oversell plans
Figure · Choosing among the three anti-oversell plans
124 / 172

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.

125 / 172

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:

126 / 172
Match
MatchMechanisms meet failures: what each weapon blocksMatched 0/6 · Missed 0
The six mechanisms you actually wrote in this article, and the dirty data each one is responsible for sealing off
Pick a card on the left first
127 / 172
Section
15. Common errors, searchable by exact wording
128 / 172

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.

129 / 172
Table
Error text (fragment)What really happened30-second fixRead 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 keyThis 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 500Section 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 itFind the slowest step in the trace; move every non-DB action into an AFTER_COMMIT listener and leave only SQLSection 3 · #31 transaction internals
org.springframework.dao.CannotAcquireLockException / Deadlock found when trying to get lockTwo transactions locked several inventory rows in different orders and are waiting on each otherLock batched deductions by ascending product_id; make sure WHERE uses a unique index instead of scanning the tableSection 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 queueFirst check for a remote call inside the transaction; then check whether spring.datasource.hikari.maximum-pool-size is still the local defaultSection 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 rollbackForThree 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 databaseSwitch to the phase listener; if you truly must send immediately, use a local outbox table plus scheduled delivery for at-least-onceSection 14 · #41 events and MQ
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-onlyAn inner @Transactional threw and marked the transaction rollback-only; the outer code caught the exception and tried to commit anyway — the mark cannot be erasedDo 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 transactionSection 14 txprop lab · #31
After editing a price the list page keeps showing the old one; or a user reads somebody else's cacheCache/database inconsistency: the write skipped @CacheEvict; or the cache key omits a parameter that changes the result, such as userIdThe key must include every parameter affecting the output; write paths always "persist, then evict"; if in doubt drop the TTL to 30s and observeSection 9 · #32 Redis and caching
130 / 172
提示

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.

131 / 172

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:

132 / 172
Triage
Error triageUnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
The inner exception was swallowed, yet the commit still failed

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.

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:803)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:752)
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:689)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:131)
at com.beeorder.order.service.OrderService.create(OrderService.java:57)
at com.beeorder.order.controller.OrderController.create(OrderController.java:41)
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
133 / 172
Section
16. Check yourself
134 / 172

A warm-up question, straight from Section 4's single line of defence:

135 / 172
Quiz
Check yourselfOnly 1 unit is left and two threads check out simultaneously. Which formulation is guaranteed to prevent oversell?
Pick one — you get feedback right away
136 / 172

Now a combined question threading Sections 3, 9 and 14:

137 / 172
Quiz
Check yourselfcreate() carries @Transactional(rollbackFor = Exception.class) and does four things in order: insert the order; call InventoryService.lock() to deduct stock; publishEvent(...) to announce the order; then this.notifySms() to call a same-class method annotated @Transactional(propagation = NOT_SUPPORTED). Which statement is correct?
Pick one — you get feedback right away
138 / 172
Section
17. Exercises in three levels
139 / 172
Section
Level 1 · Follow along: 10 threads racing for 3 units, proving "exactly 3 succeed"
140 / 172

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.

141 / 172

Step one, start a real MySQL (not H2 — row-lock behaviour will not match):

142 / 172
bash
docker run -d --name bee-mysql -p 3306:3306 \  -e MYSQL_ROOT_PASSWORD=bee -e MYSQL_DATABASE=beeorder mysql:8.0
143 / 172

Step two, create the smallest possible stock table and seed 3 units (sql/step1.sql):

144 / 172
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);
145 / 172

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:

146 / 172
Generator
GeneratorConfigure the Level 1 dependencies in one passpom.xml3 / 9
Start with just 'JDBC + MySQL driver + test' to see the minimal skeleton; layer Lombok on to cut boilerplate; finally tick Redis and MyBatis — the capabilities Level 3's 'idempotency + ledger + post-commit notice' will need
Output
<?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>
Why each choice matters
parentInheriting 3.3.4 starter-parent means no spring-boot-starter-* needs a version; the moment someone adds an explicit version to one starter, that one wins — the most common source of dependency drift.
JDBCJust the template class and HikariCP — the minimum when you refuse an ORM.
MySQL 驱动scope=runtime: not needed to compile, loaded at runtime via SPI — do not promote it to compile.
Testscope=test: @SpringBootTest, MockMvc and AssertJ live here; without it @Test is unresolved.
147 / 172

Step three, write the main class (pom.xml needs only spring-boot-starter-jdbc and mysql-connector-j):

148 / 172
java
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();    }}
149 / 172

Step four, compile and run:

150 / 172
bash
mvn -q compile exec:java -Dexec.mainClass=com.example.stock.StockApp
151 / 172

Expected output — the interleaving and ordering of the rejection lines varies, but the final tally must not:

152 / 172
text
insufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedinsufficient stock, order rejectedsuccess=3 remaining=0
153 / 172

That 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.

154 / 172

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.

155 / 172
Section
Level 2 · Variants
156 / 172

Change exactly one thing per run and the conclusion flips:

157 / 172
  1. Split the UPDATE into "read available with queryForObject, test it in Java, then update", leaving everything else alone. You will observe success= exceeding 3 and the stock going negative or being refused by UNSIGNED — that is the check-versus-act window, the real dividing line between options A/B in Section 4. Fixing it means adding FOR UPDATE and wrapping the block in a transaction, which incidentally recreates the connection-hogging scene from the Section 13 sandbox.
  2. Move the deduction into a @Transactional service method but call it via this.deduct(...) from the same class, then throw a RuntimeException afterwards. 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.
  3. Put a Thread.sleep(200) inside the transactional method to imitate a remote call, set spring.datasource.hikari.maximum-pool-size to 4, and drive it with 20 threads. You will observe Connection is not available, request timed out after 30000ms while the database itself is nearly idle — the bottleneck is connections held by transactions, not SQL.
  4. Change the listener from @TransactionalEventListener(phase = AFTER_COMMIT) to plain @EventListener and query the order back with the same JdbcTemplate inside it. You will observe occasional — often frequent — null results, because the listener runs before the commit.
158 / 172

Tip: after variant 2, reread row 5 of the Section 15 table; both tell the same story.

159 / 172
Section
Level 3 · Build one
160 / 172

Add the "idempotency + ledger + post-commit notice" trio to the Level 1 project so the loop is genuinely deliverable.

161 / 172

Requirements:

162 / 172
  • Give the order table an idempotent_key forming a unique index with user_id; on conflict catch DuplicateKeyException and return the first order number (never surface an error to the client)
  • Write one inventory_log row per deduction with a unique (ref_id, type) key, where type is one of LOCK / RELEASE / COMMIT
  • Implement closing as a conditional update WHERE status = 'CREATED', driven by a @Scheduled job scanning expired orders every minute, deduplicated across instances with a Redis setIfAbsent plus 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 test running all three test layers (unit / slice / concurrent integration), green required
163 / 172

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.

164 / 172
Section
18. Self-check: the five hard conclusions of this article
165 / 172
自检

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.

166 / 172
自检

name the three common postures in which @Transactional silently fails. Why does none of them raise an error?

167 / 172
自检

which operations must never be put inside a transaction? Answer in terms of what resource they hold, not "it is bad practice".

168 / 172
自检

why must the checkout notification hang on @TransactionalEventListener(phase = AFTER_COMMIT)? If that downstream action fails, should the order roll back?

169 / 172
自检

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?

170 / 172
口诀

deduct in one statement, keep the network out of the transaction, publish only after commit, cache only what you can afford to see stale.

171 / 172
Decision
Decisionfor BeeOrder's oversell prevention, do you choose a database optimistic lock or Redis pre-deduction?
172 / 172
Summary

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.