[{"data":1,"prerenderedAt":501},["ShallowReactive",2],{"content:article:spring-transaction-boundary":3,"content:article-surround:\u002Farticles\u002Fspring-transaction-boundary":492},{"id":4,"title":5,"body":6,"category":475,"cover":476,"date":477,"description":478,"draft":479,"extension":480,"featured":479,"meta":481,"navigation":74,"path":482,"seo":483,"stem":484,"tags":485,"updated":490,"__hash__":491},"articles\u002Farticles\u002Fspring-transaction-boundary.md","Spring 事务为什么没按预期回滚：沿着调用边界排查",{"type":7,"value":8,"toc":466},"minimark",[9,18,26,30,33,40,243,253,260,264,267,309,323,330,337,341,358,361,376,380,383,390,393,397,400,403,406,410,413,416,419,422,459,462],[10,11,12,13,17],"p",{},"“方法上已经写了 ",[14,15,16],"code",{},"@Transactional","，为什么库存减了，订单却没有创建？”这类问题不能只看注解是否存在。需要沿着一次调用确认：事务有没有开启，哪些操作加入了它，异常是否触发回滚，以及出错的动作是否本来就在事务范围内。",[10,19,20,21,25],{},"本文讨论 Spring 常见的",[22,23,24],"strong",{},"代理模式、同步 JDBC 本地事务","。响应式事务和 AspectJ 编织模式有不同语义，不混在同一结论里。",[27,28,29],"h2",{"id":29},"先把事务的业务范围说清楚",[10,31,32],{},"假设一个操作要减少库存，并在同一数据库创建订单。业务要求是两者一起成功或一起失败。这里可以用一个事务方法包住两条 SQL，前提是使用支持事务的数据库表、同一个事务管理器及其管理的数据源。",[10,34,35,36,39],{},"下面是 Spring Service 示例，需要现成的 Spring 容器、",[14,37,38],{},"JdbcTemplate"," 和数据库配置；它不是可直接运行的完整项目。",[41,42,47],"pre",{"className":43,"code":44,"language":45,"meta":46,"style":46},"language-java shiki shiki-themes github-light github-dark","import org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Service\npublic class OrderService {\n    private final JdbcTemplate jdbc;\n\n    public OrderService(JdbcTemplate jdbc) {\n        this.jdbc = jdbc;\n    }\n\n    @Transactional\n    public void place(long orderId, long productId, int quantity) {\n        if (quantity \u003C= 0) {\n            throw new IllegalArgumentException(\"购买数量必须大于零\");\n        }\n\n        \u002F\u002F 由数据库在更新语句中判断库存，避免先查再改的竞态。\n        int changed = jdbc.update(\n                \"UPDATE inventory SET available = available - ? \"\n                        + \"WHERE id = ? AND available >= ?\",\n                quantity, productId, quantity);\n\n        if (changed != 1) {\n            throw new IllegalStateException(\"商品不存在或库存不足\");\n        }\n\n        jdbc.update(\n                \"INSERT INTO orders(id, product_id, quantity) VALUES (?, ?, ?)\",\n                orderId, productId, quantity);\n    }\n}\n","java","",[14,48,49,57,63,69,76,82,88,94,99,105,111,117,122,128,134,140,146,152,157,163,169,175,181,187,192,198,204,209,214,220,226,232,237],{"__ignoreMap":46},[50,51,54],"span",{"class":52,"line":53},"line",1,[50,55,56],{},"import org.springframework.jdbc.core.JdbcTemplate;\n",[50,58,60],{"class":52,"line":59},2,[50,61,62],{},"import org.springframework.stereotype.Service;\n",[50,64,66],{"class":52,"line":65},3,[50,67,68],{},"import org.springframework.transaction.annotation.Transactional;\n",[50,70,72],{"class":52,"line":71},4,[50,73,75],{"emptyLinePlaceholder":74},true,"\n",[50,77,79],{"class":52,"line":78},5,[50,80,81],{},"@Service\n",[50,83,85],{"class":52,"line":84},6,[50,86,87],{},"public class OrderService {\n",[50,89,91],{"class":52,"line":90},7,[50,92,93],{},"    private final JdbcTemplate jdbc;\n",[50,95,97],{"class":52,"line":96},8,[50,98,75],{"emptyLinePlaceholder":74},[50,100,102],{"class":52,"line":101},9,[50,103,104],{},"    public OrderService(JdbcTemplate jdbc) {\n",[50,106,108],{"class":52,"line":107},10,[50,109,110],{},"        this.jdbc = jdbc;\n",[50,112,114],{"class":52,"line":113},11,[50,115,116],{},"    }\n",[50,118,120],{"class":52,"line":119},12,[50,121,75],{"emptyLinePlaceholder":74},[50,123,125],{"class":52,"line":124},13,[50,126,127],{},"    @Transactional\n",[50,129,131],{"class":52,"line":130},14,[50,132,133],{},"    public void place(long orderId, long productId, int quantity) {\n",[50,135,137],{"class":52,"line":136},15,[50,138,139],{},"        if (quantity \u003C= 0) {\n",[50,141,143],{"class":52,"line":142},16,[50,144,145],{},"            throw new IllegalArgumentException(\"购买数量必须大于零\");\n",[50,147,149],{"class":52,"line":148},17,[50,150,151],{},"        }\n",[50,153,155],{"class":52,"line":154},18,[50,156,75],{"emptyLinePlaceholder":74},[50,158,160],{"class":52,"line":159},19,[50,161,162],{},"        \u002F\u002F 由数据库在更新语句中判断库存，避免先查再改的竞态。\n",[50,164,166],{"class":52,"line":165},20,[50,167,168],{},"        int changed = jdbc.update(\n",[50,170,172],{"class":52,"line":171},21,[50,173,174],{},"                \"UPDATE inventory SET available = available - ? \"\n",[50,176,178],{"class":52,"line":177},22,[50,179,180],{},"                        + \"WHERE id = ? AND available >= ?\",\n",[50,182,184],{"class":52,"line":183},23,[50,185,186],{},"                quantity, productId, quantity);\n",[50,188,190],{"class":52,"line":189},24,[50,191,75],{"emptyLinePlaceholder":74},[50,193,195],{"class":52,"line":194},25,[50,196,197],{},"        if (changed != 1) {\n",[50,199,201],{"class":52,"line":200},26,[50,202,203],{},"            throw new IllegalStateException(\"商品不存在或库存不足\");\n",[50,205,207],{"class":52,"line":206},27,[50,208,151],{},[50,210,212],{"class":52,"line":211},28,[50,213,75],{"emptyLinePlaceholder":74},[50,215,217],{"class":52,"line":216},29,[50,218,219],{},"        jdbc.update(\n",[50,221,223],{"class":52,"line":222},30,[50,224,225],{},"                \"INSERT INTO orders(id, product_id, quantity) VALUES (?, ?, ?)\",\n",[50,227,229],{"class":52,"line":228},31,[50,230,231],{},"                orderId, productId, quantity);\n",[50,233,235],{"class":52,"line":234},32,[50,236,116],{},[50,238,240],{"class":52,"line":239},33,[50,241,242],{},"}\n",[10,244,245,248,249,252],{},[14,246,247],{},"inventory.id"," 与 ",[14,250,251],{},"orders.id"," 应具有主键或等价唯一约束。在通常的事务型关系数据库中，条件更新由数据库仲裁并发；对具体数据库的锁、隔离级别与执行计划，仍要结合实际环境验证。事务本身不会把所有“先查再写”代码自动变成无竞态逻辑。",[10,254,255],{},[256,257],"img",{"alt":258,"src":259},"事务代理包围本地数据库操作，远程调用在其原子范围之外","\u002Fimages\u002Fengineering-notes\u002F08-transaction-boundary.png",[27,261,263],{"id":262},"第一条线索调用经过代理了吗","第一条线索：调用经过代理了吗",[10,265,266],{},"常见错误是同一个对象内调用另一个带注解的方法：",[41,268,270],{"className":43,"code":269,"language":45,"meta":46,"style":46},"public void checkout() {\n    this.saveOrder();\n}\n\n@Transactional\npublic void saveOrder() {\n    \u002F\u002F 数据库操作\n}\n",[14,271,272,277,282,286,290,295,300,305],{"__ignoreMap":46},[50,273,274],{"class":52,"line":53},[50,275,276],{},"public void checkout() {\n",[50,278,279],{"class":52,"line":59},[50,280,281],{},"    this.saveOrder();\n",[50,283,284],{"class":52,"line":65},[50,285,242],{},[50,287,288],{"class":52,"line":71},[50,289,75],{"emptyLinePlaceholder":74},[50,291,292],{"class":52,"line":78},[50,293,294],{},"@Transactional\n",[50,296,297],{"class":52,"line":84},[50,298,299],{},"public void saveOrder() {\n",[50,301,302],{"class":52,"line":90},[50,303,304],{},"    \u002F\u002F 数据库操作\n",[50,306,307],{"class":52,"line":96},[50,308,242],{},[10,310,311,312,315,316,319,320],{},"如果 ",[14,313,314],{},"checkout"," 本身没有事务，且 ",[14,317,318],{},"saveOrder"," 仅通过这条内部调用进入，在默认代理模式下，它的注解不会被代理拦截。更清楚的结构通常是把事务操作放在独立的 Spring Bean 中，由外部调用者通过注入的 Bean 进入。",[50,321,322],{},"1",[10,324,325,326,329],{},"但不能把规则缩写成“同类调用一定没有事务”：如果外层方法已经开启事务，内部数据库操作仍可能参与外层事务；失效的是内层注解本应提供的拦截语义。例如内层声明 ",[14,327,328],{},"REQUIRES_NEW","，内部直接调用并不会凭空创建新事务。",[10,331,332,333,336],{},"手动 ",[14,334,335],{},"new OrderService(...)"," 也不是获取 Spring 代理。方法可见性还与代理类型、Spring 版本有关；本文示例选择外部调用的 public 方法，不把“只有 public 才支持”当成适用于所有版本的绝对规则。",[27,338,340],{"id":339},"第二条线索异常有没有到达事务边界","第二条线索：异常有没有到达事务边界",[10,342,343,344,248,347,350,351,354,355],{},"在未配置其他规则时，Spring 声明式事务默认针对 ",[14,345,346],{},"RuntimeException",[14,348,349],{},"Error"," 回滚，受检异常并不会仅因被抛出就默认触发回滚。可以针对业务需要配置 ",[14,352,353],{},"rollbackFor","；新版本也提供全局调整默认回滚策略的能力，因此排查时要同时看注解与全局配置。[1]",[50,356,357],{},"2",[10,359,360],{},"如果业务代码把异常捕获后返回一个失败字符串，事务代理可能只看到“正常返回”。这时不是注解突然失效，而是异常信号被消费了。可以让业务异常继续抛出，或者用明确的编程式事务策略处理，而不是随意 catch 所有异常。",[10,362,363,364,367,368,371,372,375],{},"还有相反的情况：内层通过代理参与 ",[14,365,366],{},"REQUIRED"," 事务并抛出异常，事务已经被标记为 rollback-only；外层即使捕获异常，提交时仍可能收到 ",[14,369,370],{},"UnexpectedRollbackException","。",[50,373,374],{},"3"," 因此，吞掉异常既不保证提交，也不保证回滚，要看异常经过了哪些事务边界。",[27,377,379],{"id":378},"第三条线索是不是换了线程或资源","第三条线索：是不是换了线程或资源",[10,381,382],{},"在本文讨论的常见同步本地事务中，事务资源通常绑定在线程上下文。把数据库操作扔给异步任务，不能假定它继承调用线程的同一事务。任务里是否另开事务、何时提交，需要单独设计。",[10,384,385,387,388],{},[14,386,328],{}," 也不只是“更强的事务”。它使用独立事务，内层提交后，外层再回滚不会自动撤销内层结果。外层挂起期间还可能占着连接，内层又申请新连接，连接池容量不足时容易产生新的等待问题。",[50,389,374],{},[10,391,392],{},"如果目标本来是订单与库存一起成功，把它们分成两个独立事务，反而可能破坏原本要求。传播行为应根据一致性目标选择，不是遇到异常就换一个参数试试。",[27,394,396],{"id":395},"http-成功不会被本地事务撤销","HTTP 成功不会被本地事务撤销",[10,398,399],{},"假设事务方法里先调用支付接口，再写数据库；随后写库失败。本地数据库回滚，不意味着远程支付也回滚。数据库事务没有覆盖那个 HTTP 服务。",[10,401,402],{},"类似问题还包括发短信、发邮件、上传文件。对于“提交成功后可靠发事件”，可以考虑同库写业务数据与待发送记录，再由独立任务投递的 outbox 方式，同时处理重复投递和消费幂等。仅注册一个提交后回调，并不能保证进程崩溃后消息仍会发送。",[10,404,405],{},"这不是要求所有项目立刻引入分布式事务，而是要求承诺与实际边界一致。一个简单博客后端和支付系统需要的保障不同，应按业务后果选择方案。",[27,407,409],{"id":408},"用数据库结果验证而不是看方法返回值","用数据库结果验证，而不是看方法返回值",[10,411,412],{},"验证上面的 Service，可以先准备库存，再让订单 ID 与已有记录冲突。订单插入触发异常后，从事务结束后的独立查询确认库存没有变化。成功路径则验证库存减少与订单创建同时发生。",[10,414,415],{},"集成测试也要避免测试框架自己的外层事务掩盖真实调用边界。只验证“抛出了异常”不够，必须观察最终数据库状态；只用 mock 验证调用次数，也无法证明真实数据库执行了回滚。",[10,417,418],{},"排查时把“入口 → 代理 → 业务方法 → 数据库资源 → 异常出口”串起来，很多看似随机的事务问题就会变成可定位的边界问题。注解是声明，真正决定结果的是这条调用路径。",[27,420,421],{"id":421},"参考资料",[423,424,425,439,449],"ul",{},[426,427,428,430,431,438],"li",{},[50,429,322],{}," ",[432,433,437],"a",{"href":434,"rel":435},"https:\u002F\u002Fdocs.spring.io\u002Fspring-framework\u002Freference\u002Fdata-access\u002Ftransaction\u002Fdeclarative\u002Fannotations.html",[436],"nofollow","Spring：Using @Transactional"," — 代理、自调用与可见性。",[426,440,441,430,443,448],{},[50,442,357],{},[432,444,447],{"href":445,"rel":446},"https:\u002F\u002Fdocs.spring.io\u002Fspring-framework\u002Freference\u002Fdata-access\u002Ftransaction\u002Fdeclarative\u002Frolling-back.html",[436],"Spring：Rolling Back a Declarative Transaction"," — 回滚规则。",[426,450,451,430,453,458],{},[50,452,374],{},[432,454,457],{"href":455,"rel":456},"https:\u002F\u002Fdocs.spring.io\u002Fspring-framework\u002Freference\u002Fdata-access\u002Ftransaction\u002Fdeclarative\u002Ftx-propagation.html",[436],"Spring：Transaction Propagation"," — REQUIRED、REQUIRES_NEW 与 rollback-only。",[10,460,461],{},"代码为需要数据库配置的教学片段，未在本环境启动 Spring 集成测试。资料核对日期：2026-10-08。",[463,464,465],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":46,"searchDepth":65,"depth":65,"links":467},[468,469,470,471,472,473,474],{"id":29,"depth":59,"text":29},{"id":262,"depth":59,"text":263},{"id":339,"depth":59,"text":340},{"id":378,"depth":59,"text":379},{"id":395,"depth":59,"text":396},{"id":408,"depth":59,"text":409},{"id":421,"depth":59,"text":421},"后端架构","\u002Fimages\u002Fcovers\u002Fbackend-series.jpg","2026-02-27","从扣库存与创建订单的例子，解释代理、自调用、异常规则、线程边界和远程操作的事务范围。",false,"md",{},"\u002Farticles\u002Fspring-transaction-boundary",{"title":5,"description":478},"articles\u002Fspring-transaction-boundary",[486,487,488,489],"Java","Spring","事务","数据库",null,"gc70bUt-DPpdaE-CJ_mS7cfJkOCcHuWCEAwvvMwd6Hw",[493,497],{"title":494,"path":495,"date":496},"把博客做成纯静态：Nuxt 4 生成 + Nginx 托管","\u002Farticles\u002Fnuxt-static-blog","2026-09-06",{"title":498,"path":499,"date":500},"Agent 工程化暗坑实录：CGLIB 代理、手动日志、Guardrails 与 Eval","\u002Farticles\u002Fagent-engineering-pitfalls","2026-01-26",1792063306798]