调用大模型的方法为什么不能放在数据库事务里?
简化版
数据库事务从开始到提交要一直占着一个数据库连接,事务里改过的行还会一直持有行锁。一次大模型调用要等几秒到几十秒,把它包进事务,连接和锁就跟着等几十秒,并发一高连接池就被占满,其他请求连数据库都拿不到连接。而且回滚管不到模型调用:Token 已经花掉,模型的回答也撤不回来;反过来,失败时想写进库的「失败」状态,会跟着异常一起被回滚掉。正确的做法是把流程拆开:先写库并提交,再在事务之外调模型,最后用一个短事务写结果。
详细版
一次「问答 + 落库」的请求,如果整段加了 @Transactional:
开启事务,拿到数据库连接
-> 写会话、写提问(持有连接和行锁)
-> 调大模型,等待 20 秒(连接和锁都没释放)
-> 写回答、写引用来源
提交事务,归还连接
问题有三个:
| 问题 | 原因 | 后果 |
|---|---|---|
| 连接池耗尽 | 等模型的几十秒里连接一直被占着 | 连接池只有十几个连接,并发十几个请求后其他请求全部排队或超时 |
| 锁持有时间变长 | 事务里更新过的行要到提交才释放锁 | 同一行的其他更新被挡住 |
| 回滚语义不匹配 | 模型调用不是数据库操作,回滚撤不回来 | 失败状态被回滚,页面看不到失败原因;Token 照样计费 |
拆法是「短事务 + 状态字段」:调模型之前的写入各自提交;模型调用放在任何事务之外;模型返回后,需要多条写入同时成功的部分,单独放进一个只做写库的短事务。
完整版教学
一、事务占住的是连接和锁
在 Spring 里,@Transactional 方法开始时从连接池取一个连接绑定到当前线程,方法里所有的数据库操作都用这个连接,直到方法结束才提交并归还。这段时间里有两样东西被占着:
时间轴 0s 0.1s 20.1s 20.2s
开启事务 写提问 模型返回 写回答、提交
连接 ├──────────────── 一直被占用 ─────────────────┤
行锁 ├──────────── 写过的行一直被锁 ────────┤
数据库本身在这 20 秒里几乎什么都没做,连接却不能给别人用。事务设计的前提是「事务里只有数据库操作,而且很快」,一旦中间夹进一个几十秒的外部调用,这个前提就不成立了。
二、算一笔连接池的账
常见的连接池 HikariCP 默认最大连接数是 10,取连接的默认等待超时是 30 秒。假设每次问答在事务里等模型 20 秒:
每个连接每分钟能服务的请求 = 60 / 20 = 3 次
10 个连接每分钟最多 = 10 × 3 = 30 次问答
第 11 个并发请求 要等某个连接空出来,最坏等满 20 秒
更糟的是,被挡住的不只是问答,登录、查列表、保存表单这些和 AI 无关的请求也要从同一个池子里取连接。如果模型那边变慢,每次调用从 20 秒变成 40 秒,同样 10 个连接每分钟只能服务 15 次,排队的请求开始等满 30 秒超时,整个系统看起来像是数据库挂了。
把模型调用移出事务后,每个请求只在两次写库时各占用连接几毫秒,同样 10 个连接可以支撑的并发高出几个数量级,模型慢只影响问答本身。
记忆钩子:事务里只放数据库操作,不放等待。
三、回滚管不到模型调用
事务回滚能撤销的只有本事务里的数据库修改。模型调用是一次外部 HTTP 请求:
| 事务回滚之后 | 能不能撤回 |
|---|---|
| 本事务里写的会话、提问、回答 | 能 |
| 已经发给模型的请求、已经消耗的 Token | 不能,服务商照样计费 |
| 已经通过 SSE 推给页面的内容 | 不能,用户已经看到了 |
| 模型调用期间写进另一个库(比如向量库)的数据 | 不能,不在同一个事务里 |
所以把模型调用包进事务,得不到「全有或全无」的保证,只得到了一个很长的事务。
四、失败状态会被一起回滚
很多业务要把失败记下来:文档「解析失败」、片段「向量化失败」,页面上显示失败原因,用户可以重试。常见写法是在 catch 里先把状态改成失败,再把异常抛出去:
catch (Exception e) {
// 先把失败状态写进库,页面才能显示「向量化失败」
segmentMapper.updateVectorStatus(id, "向量化失败");
// 再抛出去,让接口返回错误
throw new CustomException("向量化失败:" + e.getMessage());
}
如果这个方法加了 @Transactional,而 CustomException 继承自 RuntimeException,Spring 默认对运行时异常回滚,刚写进去的失败状态会和其他修改一起被撤销,页面上永远看不到失败标记。要让失败状态留在库里,要么这个方法不加事务,要么把写失败状态放进一个独立提交的事务。
五、正确的拆法:短事务加状态字段
第一段(各自提交):校验参数、建会话、写提问,状态记「处理中」
第二段(不在事务里):检索、调模型、等回答
第三段(短事务或各自提交):写回答、写引用、状态改「完成」
失败时(独立提交):状态改「失败」,写失败原因
拆开之后,每段写库都只占用连接几毫秒。中途失败时,第一段已经提交的数据还在,这正是需要的:提问已经记下,失败原因也能看到,用户可以重试。哪些写入需要放在同一个事务里,看它们是否必须同时成功:
| 写入 | 要不要同一个事务 | 原因 |
|---|---|---|
| 会话和第一条提问 | 可以各自提交 | 单行写入,失败了会话也只是空着 |
| 回答和它的引用来源 | 看业务 | 引用是附属记录,少几条不影响主数据时可以各自提交 |
| 删除旧片段再插入新片段 | 要 | 插入失败时旧片段也要回来,否则数据只删不增 |
| 扣余额和生成订单 | 要 | 典型的必须同时成功 |
六、Spring 里容易踩的坑
- 自调用不生效。 在同一个类里,一个没有事务的方法调用另一个带
@Transactional的方法,调用不经过代理,事务不会开启。短事务的写库方法要放到另一个 Bean 里,或者用编程式事务。 - 只对运行时异常回滚。 默认只有
RuntimeException和Error触发回滚,受检异常不回滚;需要时用rollbackFor显式声明。 - 事务不跨线程。 事务和连接绑定在当前线程上。流式输出常在子线程里推送内容,子线程里的写库不在父线程的事务里,这一点既是坑也是好处:流式回答本来就不该放进事务。
- 独立提交的失败记录。 如果外层确实需要事务,而失败状态必须留下,可以把写失败状态的方法设成
Propagation.REQUIRES_NEW,它会挂起外层事务、用新连接单独提交。
七、流式输出更要注意
流式问答一次可能持续几十秒甚至几分钟,页面上一段段出字。整个过程中服务端要等模型不断吐出内容,如果外面包着事务,连接要从第一个字一直占到最后一个字。正确的顺序是:提问在调用模型之前单独落库,回答在流结束后整段落库;用户中途停止时,这一轮回答走不到落库那一步,不会留下半截记录。
八、常见误区与追问
- 误区:加了事务,模型调用失败时数据就能回到原样。 回滚只撤销数据库修改,Token 已经花掉,推给页面的内容也收不回来。
- 误区:模型调用很快,放在事务里没关系。 模型调用常常是几秒到几十秒,而且会在服务商变慢时突然变长,连接池会在最忙的时候被拖垮。
- 误区:整个 Service 方法加一个事务最省心。 拆开之后反而更容易排查:每一段提交了什么、在哪一段失败,库里都看得到。
- 误区:失败状态写在 catch 里就一定能保存下来。 方法带事务且抛运行时异常时,失败状态会被一起回滚。
- 追问:多条写入必须同时成功,但中间要调模型,怎么办? 先调模型拿到结果,再把这几条写入放进一个只做写库的短事务里一次提交。
- 追问:怎么发现线上有长事务? 看连接池的活跃连接数和取连接等待时间,数据库侧查执行时间长的事务。
九、加强记忆
事务从开始到提交一直占着连接和写过行的锁,它的前提是「里面只有很快的数据库操作」。模型调用一等就是几十秒,放进去会让连接池按「连接数 ÷ 单次耗时」的速度被拖垮,连和 AI 无关的请求都拿不到连接;回滚又撤不回已经花掉的 Token 和推出去的内容,反而会把 catch 里写的失败状态一起撤销。所以拆成三段:调模型前的写入各自提交,模型调用不进事务,结果写入需要原子性时单独用短事务;失败状态独立提交。Spring 里还要记住自调用不生效、默认只回滚运行时异常、事务不跨线程。
项目实战落地
项目里怎么做的
《AI智能客服与工单处理系统》的 RAG 客服问答 CustomerChatService.ask() 没有加 @Transactional,整个流程拆成三段:
prepareChat() 校验问题、校验聊天和向量模型、读启用的问答模板、
创建或获取会话、保存客户提问、问题向量化、PGVector 检索
调用模型 有召回资料时调聊天模型,拿到回答
finishAnswer() 保存 AI 回答、保存引用来源、刷新会话更新时间
流式问答 askStream() 用的是同样的三段,只是中间换成流式调用。每一次写库都是单行插入或更新,各自自动提交;引用来源是循环插入多行,也各自提交。
同一个项目里真正加了事务的是「重新生成片段」:先删除这篇文档的旧片段、再插入新片段,中途插入失败时,前面删掉的旧片段也会回滚,避免数据只删不增。向量化和文档解析的方法同样不加事务,失败分支先把状态写成失败再抛异常,这一点见「知识库文档更新或删除后,片段和向量怎么保持一致?」。
为什么这样取舍
- 问答方法不加事务。 方法中间要同步等待大模型返回,包进事务的话数据库连接会一直被占用到模型响应为止;AI 一次回答往往要几十秒,并发稍高连接池就会被耗尽。
- 引用来源各自提交。 它们只是回答的附属记录,少写几条不会让主流程的数据不一致,没必要为它们开一个长事务。
- 删旧插新要加事务。 这两步必须同时成功,而且中间没有模型调用,事务很短。
面试官还会追问
- 流式问答跑在 Controller 新开的子线程里,当前登录用户是怎么传进问答流程的?为什么不在方法里自己取?
- 检索不到任何相关资料时,这一轮还会调用聊天模型吗?回答和引用来源分别怎么落库?
学完《AI智能客服与工单处理系统》,上面这些追问你都会迎刃而解。