别把数据库连接当电话线用——一个事务里调HTTP,压测直接干翻我的API
在Spring Boot应用中将HTTP调用放在@Transactional事务内会导致数据库连接池耗尽,本文分析问题原理、排查方法及两种修复方案。
数据库连接就像食堂的碗,借了不还大家都得手抓饭
写代码的时候,你有没有遇到过这种情况:系统平时跑得好好的,突然有一天就崩了,而且崩得莫名其妙。你翻来覆去查日志,发现罪魁祸首居然是一个小小的注解。对,说的就是那个@Transactional。这东西加在方法上,感觉就像给代码上了个保险,数据库操作要么全成功要么全失败,多安心啊。
但你可能不知道,这里面藏着一个大坑。你只要在注解标记的方法里顺手调了个外部的HTTP接口,比如去查一下支付信息、刷新一下令牌,平时没毛病,一旦外面那个服务卡住了,你的整个API就得跟着陪葬。
我亲身经历过一次,Stripe的Webhook处理器里面做了个获取订阅信息的操作,刚好写在事务里。结果Stripe那边抖了一下,开始重试,每个重试都卡在读超时上,默认80秒。80秒啊,数据库连接池里那点连接全被占着不放,几十个请求一来,连接池直接干空,整个系统返回503,跟Stripe半毛钱关系都没有的服务也跟着挂了。
这件事让我学到一个铁律:数据库连接是稀缺资源,比食堂饭点的碗还金贵,你借了不还,后面的人就得手抓饭。
事务就像借碗,外部调用就像端着碗去隔壁街排队买奶茶
先搞清楚一个概念。数据库连接池这东西,你可以把它想象成食堂里的一摞碗。平时吃饭的人多,食堂阿姨备了60个碗,每个人来吃饭,递给你一个碗,吃完了洗干净还回去,下一个人接着用。这个碗就是数据库连接。@Transactional注解的作用是什么?就是你跟阿姨说,我要吃一顿完整的饭,从打菜到喝汤,中间不能有人打断我,这个碗我一直端着,直到我全部吃完再还。这本来没问题,食堂里转一圈顶多几分钟。
但是你要是干了件蠢事。你端着碗,出食堂门了,跑到隔壁街奶茶店排队买奶茶。队伍老长了,前面二十个人,店员动作还慢,一杯奶茶做了80秒。你手里那个碗就一直在你手上,80秒没还回去。食堂里后面来的人一看,碗呢?没了。60个人出去买奶茶,60个碗全被端着站在奶茶店门口,食堂里几百号人饿着肚子等碗。这就叫连接池耗尽。
你的代码就是这样。@Transactional方法里做了个HTTP调用,去Stripe查订阅信息。这个调用就是隔壁街的奶茶店。数据库连接在你手上,你跑去等网络响应了。平时Stripe响应快,几毫秒就回来,没问题。但万一Stripe那边网络抖一下,或者它自己的服务慢了一点,你的代码就卡在读取超时那一步。默认80秒。80秒内,这个数据库连接死死攥在你手里,不还。80秒够后面的请求把连接池里所有连接都耗光。
更隐蔽的是,这个方法平时根本看不出问题。因为大部分时候网络调用是快的,连接用完就还,一切正常。只有在外部分服-务出问题的时候,这个漏洞才会暴露。而一旦暴露,就是毁灭性的。你的整个API,不管跟Stripe有没有关系的接口,全部返回503。用户登不上、数据查不了、订单下不成,全因为一个跟你业务核心无关的支付回调卡住了。
所以说,事务里做外部调用,相当于你端着食堂的碗去隔壁街买奶茶。食堂阿姨没拦你,但后面吃饭的人会骂娘。
连接池不是无底洞,每个请求都在消耗你的信用额度
连接池的大小不是随便设的。HikariCP默认是10,你自己可能调到了60,觉得够用了。但你要算一笔账。每个HTTP调用的超时时间设了多久?如果是默认的80秒,那60个连接全部被占满只需要60个并发请求。60个并发高吗?对于一个有25000用户的系统来说,一点都不高。Stripe那边一重试,重试风暴一来,瞬间就能把你60个连接全部打满。
更可怕的是,这些被占住的连接,其实什么数据库操作都没做。它们只是开着事务,等着网络调用返回。数据库那边看到的是什么?是一条空闲的事务,挂在那里,啥也没干。PostgreSQL里查pg_stat_activity,一堆idle in transaction。这种状态意味着事务已经开了,但没有任何SQL在执行。数据库不知道你在等什么,它只知道你这个连接还活着,事务还没提交。如果有长事务的监控告警,这时候就该炸了。
那你怎么发现这个问题?不用等到系统崩了才拍大腿。几个信号可以提前看到。
第一个是日志里出现SQLTransientConnectionException,说连接不可用,请求超时了。这是HikariCP在喊救命,它已经尽力了,但实在是借不到连接了。
第二个是看HikariCP的监控指标。Spring Boot Actuator加上Micrometer,能看到hikaricp.connections.active这个数是不是一直顶在最大值,还有hikaricp.connections.pending是不是大于零,说明有人在排队等连接。
第三个是最有用的,设置连接泄漏检测阈值。spring.datasource.hikari.leak-detection-threshold设成20秒,如果哪个连接被占用了超过20秒还没还,HikariCP就会打一条警告日志,里面带着堆栈信息,直接告诉你哪个方法的哪行代码把连接攥着不放。拿着这个堆栈去找,一抓一个准。
还有一招是看线程转储。/actuator/threaddump或者jstack打出来,你会看到一堆线程卡在HikariPool.getConnection上等连接,而另外几个线程卡在socketRead上,那就是在等网络响应。证据链就齐了。
网络不是数据库,它不跟你保证任何事
很多人搞混一件事。数据库连接是个靠谱的资源,你拿到连接,执行SQL,数据库会给你返回结果,要么成功要么失败,超时了也会报错。但网络调用不是这样。HTTP请求出去的这条路,充满了不确定性。对方可能慢、可能挂、可能返回一个乱七八糟的东西。更关键的是,网络调用没有事务的概念。
你把数据库事务和网络调用混在一起,等于让一个靠谱的机制去等一个不靠谱的结果。事务说,我要保证数据一致性,所有的数据库操作要么全做要么全不做。但它没办法控制外面的HTTP请求。网络超时了怎么办?重试了怎么办?对方服务降级了怎么办?事务通通不管,它只知道你的代码还没执行完,连接不能还。
于是你陷入了一个两难。要么等网络调用结束,连接一直占着,系统随时可能崩。要么你给网络调用设个很短的超时,比如5秒,但这样业务又可能失败,因为外面服务确实有时候需要更长的时间。你设长了系统扛不住,设短了业务不稳定。这是个死结。
唯一的解法就是把这两个东西拆开。网络调用归网络调用,事务归事务。别让它们混在一起。先做网络调用,拿到结果,再开事务写数据库。或者反过来,先开事务读数据,提交事务,再做网络调用。总之,不要让事务的生命周期包含网络调用。
这里面有个细节要注意。有些人会说,那我先开事务,做数据库操作,然后提交事务,最后做网络调用。这样行不行?行,但前提是网络调用的结果不需要再写回数据库。如果你需要根据网络调用的结果来决定写什么数据,那你就得先做网络调用。拿Stripe webhook的例子来说,你收到一个事件,需要去Stripe查一下当前订阅的最新状态,然后根据这个状态更新自己数据库里的订单。那顺序应该是:先调Stripe接口拿到订阅信息,再开事务,把订阅信息写进数据库,提交事务。网络调用在外面,事务只包数据库写的那一小块。
改代码就像拆雷,找到线头轻轻一拉就活
具体怎么改?两个方向。
第一个是立竿见影的补丁,给HTTP调用设明确的超时时间。Stripe SDK有默认的超时,但你不能信那个默认值。自己设,连接超时5秒,读超时15秒,重试最多2次。这样就算Stripe那边卡了,最多30几秒你的调用就会失败,不会占着连接80秒不撒手。这个改法不需要改代码结构,加几行配置就行,能快速止血。
但真正的治本之策是改结构。把HTTP调用从@Transactional方法里挪出去。原来你的代码可能是这样写的:
@Transactional |
改完之后,拆成两个方法。第一个方法做HTTP调用,拿到订阅信息。第二个方法是个干净的事务,只负责写数据库。调用顺序变成先网络后数据库。
public void handleWebhook(Event event) { |
就这么简单。事务的边界从原来包裹整个方法,缩小到只包裹数据库写入的那几行。网络调用在外面,占用的只是普通线程,不牵扯数据库连接。线程池和连接池是两个东西,线程可以等80秒,但连接不能。
那有人说,我原来的事务里做了好几件事,有读有写,外部调用夹在中间怎么办。比如先从数据库读一个订单,然后调外部接口,然后根据结果再写数据库。这种情况,要么你把外部调用提到最前面,依赖的数据提前拿到。要么你把事务拆成两个,读数据开一个只读事务,拿到结果后提交,再做外部调用,最后再开一个写事务。当然这样会有并发问题,两个事务之间数据可能变了。那就需要用乐观锁或者版本号来控制,那是另一个话题了。
还有一种情况,外部调用的结果需要作为事务的一部分,如果外部调用失败,整个事务要回滚。这其实是个伪需求。外部调用已经发生,Stripe那边已经扣了钱或者发了消息,你怎么回滚?你只能补偿。补偿的意思是,如果后面数据库写失败了,你再调另一个接口去退款或者撤销操作。这已经不是数据库事务能解决的问题了,需要分布式事务或者Saga模式。但大多数场景下,你真的需要这么严格的一致性吗?先做外部调用,成功后再写数据库,如果写失败了,记一条日志或者发个告警,人工处理或者异步重试,往往就够了。
高阶玩法:外部调用扔到一边,事务自己玩自己的
前面说的两阶段方法,先HTTP后事务,已经能解决绝大多数问题。但有些场景下,事务需要包含多个外部调用的结果,或者外部调用的结果要触发多个数据库操作,而且这些操作必须在一个事务里完成。这时候两阶段就不够了,因为外部调用可能有多个,顺序执行耗时太长,而且中间任何一个失败,整个流程就断了。
真正的修正是重构代码结构。原则很简单:别在事务里做外部IO。
具体做法分两步。第一步,先调所有外部HTTP接口,把所有需要的数据拿到手。第二步,开一个短事务,只做数据库写操作。
伪代码逻辑大概是这样的:
先调Stripe查订阅,拿到订阅数据。然后手动开一个事务,把数据写进数据库,立刻提交。
这样做的好处是,外部调用的等待时间完全不占数据库连接。就算Stripe慢成狗,也就自己慢慢等去,不影响别人。
最关键的区别是时间线的变化。修复前,从拿连接到释放连接,中间包含了整个HTTP等待时间。修复后,事务只在数据库写操作那几十毫秒里存在,HTTP等待时间全在外面。
为什么这件事很多人都踩过
我发帖之后好几个人回复说遇到过类似问题。有个哥们说他的事务里调了Twilio的接口,也是GET请求,然后才写数据库。Twilio的数据量大的时候响应慢,直接把Oracle连接池耗干了,报连接已关闭的错误。
还有人说用了十年Spring,以为所有坑都踩过了,结果这个坑还没踩过,感谢提醒。
这说明一个问题:这个错误太容易犯了。@Transactional注解太方便了,加一下就行,数据库操作自动就有事务保证。你脑子里想的是“我得保证这些数据库操作的一致性”,顺手把方法一标,完全没意识到里面混了别的IO。
注解本身没问题,问题在于它把连接获取和释放的范围给模糊了。你看着是一个方法,实际上数据库连接从方法开始就拿了,到方法结束才放。方法里如果有网络调用,网络调用就白占着连接。
两种主流解决方案的取舍
解决这个问题主要有两条路,各有利弊。
第一种就是我用的两阶段法。外部IO全放前面,手动开事务只包数据库写操作。好处是简单直接,控制精确,事务时间极短。坏处是代码不那么优雅,事务边界得自己管理,有时候需要把外部调用的结果存在局部变量里再传进事务块。
第二种是用发件箱模式(outbox pattern)。不直接在请求里调外部服务,而是先把要发的东西写进数据库的一张“发件箱”表,提交事务,然后有个后台进程扫描这张表,异步发出去。好处是彻底解耦,主事务完全不受外部服务影响。坏处是复杂度上来了,得处理重试、顺序、幂等等问题。
我在Stripe的webhook上用了两阶段,因为webhook需要同步返回成功或失败,Stripe要根据返回值决定要不要重试,不适合做异步。但在Google同步的那个场景用了发件箱,因为那个不需要实时响应,异步慢慢发就行。
一个更隐蔽的变种问题
还得提一个变种。不只是HTTP调用,任何慢的外部IO放事务里都有同样问题。比如调用另一个微服务、读一个慢的Redis操作、甚至是一个计算量大的本地操作。
严格来说,事务只应该包真正需要原子性的数据库操作。其他所有事情,包括校验参数、调用外部服务、做复杂计算、写日志,都应该放在事务外面。
一个更激进的规则是:事务里的代码应该只有SQL和极少的业务逻辑。如果一个方法里有超过五条SQL,或者有循环、有条件分支,可能就该考虑拆分一下了。
怎么从架构上避免这类问题
写完代码你以为安全了?还得学会看体检报告
代码改完了,超时设了,顺序也调了。然后就高枕无忧了?别急,你得学会观察你的系统有没有复发的迹象。就像治好了病还得定期体检,代码也一样,你得知道怎么看体检报告。
第一种体检项目:看日志里的关键字。如果你的代码又开始犯同样的错误,第一个信号通常是这样的异常信息:SQLTransientConnectionException: Connection is not available, request timed out after 30000ms。翻译过来就是:连接不可用,等了30秒都没等到。
这个异常是HikariCP连接池抛出来的。它想拿一个数据库连接,但等了30秒池子里还是空的。数据库本身没问题,应用程序也没崩,就是连接全被别人占着不放。只要看到这个异常,你就要高度怀疑:是不是又有人在事务里干了不该干的事。
第二种体检项目:打开Spring Boot Actuator的指标。HikariCP自带一大堆有用的指标,通过Micrometer暴露出来。你需要关注三个数字:hikaricp.connections.active是当前活跃的连接数,如果它一直顶着最大值不动,说明池子满了;hikaricp.connections.pending是正在排队等着拿连接的线程数,如果这个数字大于0,说明有人在等;hikaricp.connections.usage是连接从借出到归还的时间,正常应该只有几毫秒到几十毫秒,如果经常出现几百毫秒甚至几秒,说明有人在占着茅坑不拉屎。
这三个指标组合在一起,就能画出完整的画像:活跃数满了、排队数在涨、单次使用时间很长。这就是连接池耗尽的典型特征。
第三种体检项目:开启泄漏检测。HikariCP有个特别有用的配置叫leak-detection-threshold。你把它设成20000毫秒,意思是如果一个连接被借出去超过20秒还没还,它就记一条警告日志,并且把当时拿着这个连接的线程堆栈打印出来。
这个功能简直是抓坏蛋的神器。因为它能直接告诉你:就是这行代码,就是在这个方法里,连接被你霸占了20秒没释放。你拿到堆栈一看,哦,原来是在调HTTP,或者是在等某个锁,或者是在打日志打得太嗨了。一目了然。
第四种体检项目:抓线程快照。如果问题已经发生了,你可以通过/actuator/threaddump端点或者jstack命令把当前所有线程的堆栈抓下来。然后找那些在等连接的线程,再看看那几个真正持有连接的线程在干嘛。大概率你会发现,持有连接的线程阻塞在某个网络调用的read方法上。证据确凿。
第五种体检项目:查数据库那边的状态。如果用的是PostgreSQL,可以执行一条SQL:SELECT state, count(*) FROM pg_stat_activity GROUP BY state。你会看到一堆idle in transaction的状态。这个状态的官方翻译是“事务开着,但啥也没干”。这正是HTTP调用卡在事务里的铁证。数据库在等你提交事务,但你的代码正在网络上飘着。
把这些信号组合起来,你就有了一个完整的诊断工具链。连接池指标告诉你出问题了,泄漏检测告诉你是谁干的,线程快照告诉你在哪一行,数据库状态告诉你干了多久。拿到这四张牌,你就可以底气十足地去改代码了。
千万别等到用户投诉了你才去查问题。把这些监控指标做到你的运维仪表盘上,设置好告警规则。比如活跃连接数超过池子大小的80%就报警,pending队列超过10就报警,usage时间超过1秒就报警。让系统自己告诉你它快撑不住了,而不是等到503了才后知后觉。