4.3 JDBC会话与事务演练


4.3 JDBC会话与事务演练

本节摘要:事务把多个数据库动作绑成"要么全成、要么全不算"的原子单元:开启事务后执行的一组语句,只有提交才生效,任何一步失败即整体回滚。下单流程里"扣库存"与"写订单"正是这样一对同生共死的动作。本节是第 4 章手术的收尾缝线——事务边界必须画在服务层,连接资源必须不泄漏,这两条做到,改造后的站点才敢真正接订单。

一笔订单的账为什么不能错

下单这个业务动作,在数据库里其实是两个动作:订单表插入一行、图书表库存减一。两条语句各自成功或失败本无约束,但业务上它们必须同进退——只写订单没扣库存,等于顾客用空气买走了书;只扣库存没写订单,则是顾客付了钱、书凭空蒸发。两类错账都会直接变成资损工单。

数据库对此的答案是事务:把两条语句包进同一个单元,末尾提交则一起生效,中途出错则整体撤销,数据库回到开工前的状态。关键在于边界画在哪。上一章的四包结构已经给出答案:一个业务动作一个事务,边界落在服务层方法上——控制器只管调 place 方法,事务在方法内部开启与结束,对上层完全透明。

服务层的事务骨架:一段可以直接背的代码

下单服务的事务块值得整段精读,每一行都在防一种事故:

public Order place(String bookId, String userId) { try (Connection conn = pool.getConnection()) { conn.setAutoCommit(false); // 第一防线:关掉自动提交 try { int rows = bookDao.deductStock(conn, bookId, 1); if (rows == 0) { // 库存扣不动:没有行被更新 conn.rollback(); // 主动回滚,礼貌结束 throw new BizException("库存不足"); } String orderNo = orderDao.insert(conn, bookId, userId); conn.commit(); // 两条都成了,一起生效 return new Order(orderNo); } catch (BizException e) { conn.rollback(); // 兜底回滚,不留半截账 throw e; } catch (Exception e) { conn.rollback(); // 任何意外都要回滚 throw new RuntimeException("下单失败", e); } } }

逐行拆解它的防御体系。关掉自动提交是第一防线——默认模式下每条语句独立生效,那就没有"整体"可言。库存扣减用影响行数判断成败,而不是先查再改:先查再改在并发下会查到同一个"还有货",两单同时扣走最后一本,这就是超卖。回滚写了三处,看似重复,实则覆盖三条路径:业务失败主动回滚、业务异常兜底回滚、意外异常也回滚——宁可多写,不可漏一条。连接用完即还,依赖连接池的归还机制。

失败路径的完整时序值得画出来对照。

图4-3 下单事务的成功与回滚两条路径

图4-3 下单事务的成功与回滚两条路径

连接不泄漏:比事务更隐蔽的雷

事务代码写对还不够。连接是池化的稀缺资源,借了不还,池子很快见底——站点表现为"跑着跑着全部请求卡死,重启恢复",这是老站运维群最常见的故障报告之一。泄漏往往藏得极深:某条异常路径提前 return,连接没走到关闭语句;或者把连接存进了会话作用域,打算"复用"——这是双重错误,既泄漏又踩了 2.2 节的共享红线。

固定写法是在服务方法最外层用自动归还的写法包住整个事务块(上面示例的第一行正是如此),无论 return 还是抛异常,归还逻辑必然执行。数据访问方法则一律"从参数里拿连接、用完不关"——关闭与归还的职责统一收在服务层,层次清晰,也不会出现"数据访问层好心关了连接、事务却还没提交"的错乱。

案例:改造前的那批孤儿订单

背景:翻青梧书肆的运维记录,改造前有一批"幽灵订单":订单表里有记录,库存却没扣——顾客的订单状态永远停在处理中。当年代码里先写订单、后扣库存,扣库存那天恰好赶上库存表锁等待超时,异常中断,订单却已经落库。

操作:按本节的骨架把下单逻辑包进服务层事务,扣库存挪到写订单之前(库存扣不动时连订单都不该有),影响行数判断替换先查后改。

结果:上线后压测一轮:并发二百单抢十本库存,最终恰好成交十单、库存归零、无孤儿订单、无超卖。

解读:这轮压测同时验证了两件事——事务保证了账目一致,行数判断保证了并发安全。老代码"先查还有货吗、再扣"的写法在单线程年代没出过事,并发上来后必然超卖:检查与扣减之间的空隙,就是别的请求插队的窗口。把"检查"融进"扣减"本身(影响行数为零即失败),空隙就消失了。

变式:并非所有数据库操作都需要事务。单条只读查询不值得开事务;批量导入几千条数据反而要分段提交——一个大事务撑到最后才提交,回滚段压力与锁持有时间都会失控,分批提交每千条一次是稳妥的做法。事务的粒度跟着业务动作走,不必教条。

⚠️ 常见坑:在事务块里混入远程调用(发通知、调支付接口)。远程调用慢且易超时,把它放进事务等于让数据库锁着资源等网络——连接池会被迅速占满。纪律是:事务里只放数据库动作,通知类动作在提交成功之后再发。

隔离级别的初识与默认策略

事务还有一层在本书场景里只需初识的属性:隔离级别。它回答的是"一个事务执行中途,别的连接能不能看到它的半成品"。默认级别下,未提交的扣减对其他事务不可见,这挡住了绝大多数脏读事故;青梧书肆全站沿用默认级别,没有为任何业务调高或调低过它——这是维护期的推荐姿势。调级别属于专家动作:调高换一致性、付并发吞吐的账,调低换吞吐、自己补一致性检查,两头都要有对应的测试兜底才敢动。第 4.3 节压测里"二百单抢十本恰好成交十单"的结果,正是默认级别加行数判断配合出来的,说明默认值配对纪律就够了。把"没动过隔离级别"写进交接文档,也是一种信息——它告诉接手者排障时先从应用层找原因,别一头扎进数据库的深水区。

事务块的自查五问

事务代码写完,五问自查一遍再提交。一问:所有写操作都在同一个连接上吗?——借了两个连接各写各的,事务边界形同虚设。二问:回滚覆盖了所有失败路径吗?——业务失败、业务异常、意外异常,三处缺一不可。三问:提交之后才发的外部动作吗?——发通知、写缓存在提交前执行,回滚就撤不回去了。四问:批量写会分批提交吗?——几千条数据一个事务撑到底,回滚段与锁持有都在冒险。五问:连接归还走的是自动归还写法吗?——手工归还的写法,一条早退路径就是一处泄漏。五问全过,事务块才算过关。青梧书肆把这五问做成评审模板挂在服务包的注释里,评审会照单核,效率远高于"大家看看有没有问题"。清单化是老站工程化里最廉价也最有效的手段,事务块只是它众多用武之地之一。

本节要点回顾

  • 同生共死的动作包进事务:扣库存与写订单是典型组合,只成一半就是资损事故;
  • 边界画在服务层:一个业务动作一个事务,控制器对事务无感知,业务动作保持可复用;
  • 行数判断防超卖:用影响行数代替先查再改,检查与扣减之间的并发空隙即告消失;
  • 连接必有归还:自动归还的写法包住整个事务块,数据访问层只借不还,职责统一在服务层;
  • 事务里不放远程调用:数据库动作先行,通知类动作提交后再发,锁持有时间最小化。

至此,MVC 手术完成:请求有门、业务有层、账目有底。但页面里还残留着最后一批脚本片段——取值和循环。下一章请出 EL 与 JSTL,把视图彻底洗干净。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U