文章目录
- 每日一句正能量
- 前言
- 1. 背景与问题
- 1.1 连接超时
- 1.2 语句超时
- 1.3 事务超时
- 2. 环境与数据
- 3. 复现过程
- 3.1 复现连接池等待超时
- 3.2 复现 SQL 超时
- 3.3 复现事务超时
- 4. 方案实施
- 4.1 先建立超时配置矩阵
- 4.2 HikariCP:连接池超时配置
- 4.3 JDBC 驱动 connectTimeout
- 4.4 JDBC:单条语句超时
- 4.5 MyBatis:超时配置
- 4.6 JPA / Hibernate 适配
- 4.7 Spring 事务超时
- 4.8 异常与事务边界:不要吞超时异常
- 4.9 记录哪一种超时真正发生
- 4.10 时间线必须可观测
- 5. 结果对比
- 实施前
- 实施后
- 6. 风险与复盘
- 6.1 超时不是越短越好
- 6.2 超时值不能倒挂
- 6.3 事务里不要夹远程调用
- 6.4 SQL 超时不代表数据库立即停止
- 6.5 重试必须配合幂等
- 结语
每日一句正能量
风柔草木舒,心静天地宽
春赏花开成海,冬观雪落如诗,
夏听蝉鸣入梦,秋藏明月满怀。
四季皆赠你温柔,时光皆许你从容。
前言
很多在线接口的数据库故障,并不是“没有配置超时”,而是所有地方都配置了超时,却彼此打架。
一个常见现场是这样的:HTTP 接口超时 3 秒,数据库连接池超时 3 秒,SQL 超时 3 秒,事务超时也是 3 秒。表面上看每层都有保护,实际上谁先触发完全取决于当时系统状态。连接池拥塞时,请求可能 3 秒都耗在拿连接;SQL 锁等待时,接口先被上游取消,但数据库语句还在执行;事务里夹着远程调用时,单条 SQL 都很快,整个事务却拖到十几秒。
因此,连接超时、语句超时和事务超时不能独立设计。它们应该形成一套有内外层次、有异常边界、有回滚策略的治理矩阵。
1. 背景与问题
先明确三个概念。
1.1 连接超时
连接超时通常有两层。
第一层是连接池等待超时,例如 HikariCP 的:
spring:datasource:hikari:connection-timeout:300它控制的是:业务线程最多等多久才能从连接池拿到一个连接。
第二层是 JDBC 驱动建立网络连接的超时,例如 MySQL:
connectTimeout=1000它控制的是:驱动连接数据库地址时,网络建连最多允许多久。
这两个超时经常被混为一谈。实际上一个发生在“池里没连接可借”,另一个发生在“驱动正在建新连接”。
1.2 语句超时
语句超时针对一条 SQL,例如 JDBC:
statement.setQueryTimeout(1);单位通常是秒。
它的核心目标是限制:
SELECT / UPDATE / INSERT / DELETE单条语句的最大执行时间。
这类超时尤其适合防止:
- 锁等待;
- 执行计划劣化;
- 全表扫描;
- SQL 意外放大;
- 大结果集拖垮线程。
1.3 事务超时
事务超时约束的是一组操作,而不是一条 SQL。
例如 Spring:
@Transactional(timeout=2)publicvoidplaceOrder(){...}它保护的是完整事务生命周期:
BEGIN SQL-1 业务逻辑 SQL-2 远程调用 SQL-3 COMMIT / ROLLBACK因此,单条 SQL 可能都只执行 100 ms,但事务仍然可能超过 2 秒。
工程上最重要的不是三个值分别是多少,而是它们之间的层级关系。通常建议:
连接池等待超时 < SQL 超时 < 事务超时 < HTTP 接口总超时这不是绝对公式,但它能让故障优先在更靠近根因的层级暴露。
2. 环境与数据
本文示例使用:
JDK 21 Spring Boot 3.3+ MySQL 8.0+ HikariCP Spring JDBC MyBatis 3.x Hibernate 6 / JPA测试表:
CREATETABLEinventory(idBIGINTPRIMARYKEY,skuVARCHAR(64)NOTNULL,availableINTNOTNULL,versionINTNOTNULLDEFAULT0,updated_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP);INSERTINTOinventory(id,sku,available)VALUES(1,'SKU-001',100),(2,'SKU-002',100);再建立订单表:
CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULLUNIQUE,skuVARCHAR(64)NOTNULL,quantityINTNOTNULL,statusVARCHAR(32)NOTNULL,created_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP);在线接口模拟:
POST /orders逻辑为:
扣库存 -> 写订单 -> 提交事务3. 复现过程
3.1 复现连接池等待超时
先把连接池调小:
spring:datasource:hikari:maximum-pool-size:2minimum-idle:2connection-timeout:300构造一个故意占用连接的接口:
@RestController@RequiredArgsConstructorpublicclassDebugController{privatefinalDataSourcedataSource;@GetMapping("/debug/hold-connection")publicStringholdConnection()throwsException{try(Connectionconnection=dataSource.getConnection()){Thread.sleep(5000);return"ok";}}}并发调用三次。
前两个请求各占一个连接,第三个请求等待 300 ms 后失败。
常见异常类似:
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 300ms这里数据库可能完全健康,只是连接池被占满。
如果没有单独记录poolWaitMs,开发人员很容易误以为是 SQL 慢。
3.2 复现 SQL 超时
MySQL 可以使用:
SELECTSLEEP(5);JDBC 测试:
@GetMapping("/debug/query-timeout")publicStringqueryTimeout()throwsException{try(Connectionc=dataSource.getConnection();PreparedStatementps=c.prepareStatement("SELECT SLEEP(5)")){ps.setQueryTimeout(1);try(ResultSetrs=ps.executeQuery()){return"unexpected";}}}理论上 1 秒左右即可触发查询超时。
异常通常会表现为 JDBC 驱动抛出的超时异常,例如:
java.sql.SQLTimeoutException或驱动自己的异常子类。
重要的是:语句失败并不自动等于整个业务事务一定已经回滚。
如果这条 SQL 在事务中执行,接下来该不该继续,取决于异常是否向上抛出、事务管理器是否把事务标记为 rollback-only,以及业务代码有没有错误地吞掉异常。
3.3 复现事务超时
代码:
@Service@RequiredArgsConstructorpublicclassOrderService{privatefinalJdbcTemplatejdbcTemplate;@Transactional(timeout=2)publicvoidcreateOrder(StringorderNo)throwsInterruptedException{jdbcTemplate.update(""" UPDATE inventory SET available = available - 1 WHERE id = 1 AND available > 0 """);Thread.sleep(2500);jdbcTemplate.update(""" INSERT INTO orders(order_no, sku, quantity, status) VALUES (?, 'SKU-001', 1, 'CREATED') """,orderNo);}}这里第一条 SQL 很快,真正拖慢事务的是Thread.sleep(2500)。
因此,事务超时不是“SQL 超时的另一种写法”,它负责识别:
事务生命周期过长而不是:
某条 SQL 执行过长4. 方案实施
4.1 先建立超时配置矩阵
一个在线接口可以先从下面的层次开始:
连接池等待超时:300 ms 驱动 connectTimeout:1 s SQL 超时:800 ms 事务超时:2 s HTTP 接口总超时:3 s这些数值仅用于演示,不应直接复制到生产。
生产上至少要参考:
接口 P95 / P99 数据库 P95 / P99 连接池大小 平均事务 SQL 数量 锁等待特征 上游调用 SLA 峰值并发核心思想是:越靠近底层资源,超时越早暴露;越靠近业务边界,允许的预算越大。
4.2 HikariCP:连接池超时配置
推荐:
spring:datasource:hikari:maximum-pool-size:20minimum-idle:5connection-timeout:300validation-timeout:500idle-timeout:600000max-lifetime:1800000这里最关键的是:
connection-timeout它不是 SQL 执行超时。
当连接池耗尽时,线程最多等待 300 ms。
如果把这个值设成 5 秒甚至 30 秒,会产生一种危险现象:
数据库已经拥塞 -> 请求继续堆在连接池 -> Web 线程被占满 -> 上游继续重试 -> 雪崩在线接口通常更适合快速失败。
4.3 JDBC 驱动 connectTimeout
MySQL JDBC URL 可以配置:
spring:datasource:url:>jdbc:mysql://127.0.0.1:3306/demo ?connectTimeout=1000 &socketTimeout=2000注意两个参数含义不同。
connectTimeout用于 TCP 建连阶段。
socketTimeout通常约束驱动等待服务器网络响应的时间。
不要把socketTimeout当作精确 SQL 超时的唯一手段,因为它更靠近网络 I/O 层,粒度没有Statement.setQueryTimeout()那么清晰。
4.4 JDBC:单条语句超时
最直接的方式:
publicintupdateStock(longid)throwsSQLException{try(Connectionc=dataSource.getConnection();PreparedStatementps=c.prepareStatement(""" UPDATE inventory SET available = available - 1 WHERE id = ? AND available > 0 """)){ps.setLong(1,id);ps.setQueryTimeout(1);returnps.executeUpdate();}}如果项目使用JdbcTemplate,可以配置:
@BeanJdbcTemplatejdbcTemplate(DataSourcedataSource){JdbcTemplatetemplate=newJdbcTemplate(dataSource);template.setQueryTimeout(1);returntemplate;}这会影响通过该模板执行的 SQL。
如果业务里有“普通 SQL”和“报表 SQL”两种 SLA,更建议创建不同模板:
@BeanJdbcTemplateonlineJdbcTemplate(DataSourceds){JdbcTemplatet=newJdbcTemplate(ds);t.setQueryTimeout(1);returnt;}@BeanJdbcTemplatereportJdbcTemplate(DataSourceds){JdbcTemplatet=newJdbcTemplate(ds);t.setQueryTimeout(10);returnt;}不要为了一个慢报表把所有在线 SQL 的超时都拉长。
4.5 MyBatis:超时配置
MyBatis XML 可以直接配置:
<selectid="findInventory"resultType="com.demo.Inventory"timeout="1">SELECT id, sku, available, version FROM inventory WHERE id = #{id}</select>也可以设置默认值:
mybatis:configuration:default-statement-timeout:1推荐原则:
默认值兜底 关键 SQL 单独覆盖例如后台报表可以设为:
timeout="10"而在线库存扣减仍维持 1 秒。
4.6 JPA / Hibernate 适配
JPA 查询可以设置 Hint:
TypedQuery<OrderEntity>query=entityManager.createQuery(""" select o from OrderEntity o where o.orderNo = :orderNo """,OrderEntity.class);query.setParameter("orderNo",orderNo);query.setHint("jakarta.persistence.query.timeout",1000);这里常见单位为毫秒,但不同 Provider 的具体行为要以实现和驱动为准。
Hibernate 也可以:
query.unwrap(org.hibernate.query.Query.class).setTimeout(1);setTimeout(1)通常表示秒。
项目里最忌讳的是:
JPA Hint = 1000 Hibernate timeout = 1000 JDBC queryTimeout = 1000因为单位可能完全不同。
必须在代码评审规范里明确写清:
哪个 API 的单位是秒 哪个 API 的单位是毫秒4.7 Spring 事务超时
Spring:
@Transactional(timeout=2)publicvoidcreateOrder(...){...}也可以在事务模板中配置:
TransactionTemplatetemplate=newTransactionTemplate(transactionManager);template.setTimeout(2);template.execute(status->{// business logicreturnnull;});事务超时最大的价值是防止:
长事务特别是:
事务里调用 HTTP 事务里调用 RPC 事务里等待 MQ 事务里执行复杂计算这些操作本身可能与数据库无关,但它们会延长连接与锁的占用时间。
4.8 异常与事务边界:不要吞超时异常
这是线上最容易写错的地方。
错误示例:
@Transactional(timeout=2)publicvoidcreateOrder(){try{jdbcTemplate.queryForObject("SELECT SLEEP(5)",Integer.class);}catch(Exceptione){log.warn("sql timeout, ignore",e);}jdbcTemplate.update(""" INSERT INTO orders(order_no, sku, quantity, status) VALUES ('O-1001', 'SKU-001', 1, 'CREATED') """);}如果异常被吞掉,事务语义会变得难以预测。
更安全的做法是:
@Transactional(timeout=2)publicvoidcreateOrder(){try{executeBusinessSql();}catch(DataAccessExceptione){log.error("database operation failed",e);throwe;}}让事务管理器看到异常。
如果业务确实需要捕获异常,也应显式决定事务状态:
catch(DataAccessExceptione){TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();thrownewOrderCreateException("订单创建失败",e);}4.9 记录哪一种超时真正发生
结构化日志推荐:
{"event":"db_timeout","traceId":"2c34c4b8b83f4f6e","txId":"8a921f32","timeoutType":"STATEMENT","poolWaitMs":8,"sqlDurationMs":1001,"transactionDurationMs":1240,"sqlState":"HY000","exception":"java.sql.SQLTimeoutException"}可以统一枚举:
publicenumTimeoutType{CONNECTION_POOL,CONNECT,SOCKET,STATEMENT,TRANSACTION,HTTP}并在异常处理器中分类:
publicTimeoutTypeclassify(Throwablee){if(einstanceofSQLTimeoutException){returnTimeoutType.STATEMENT;}if(einstanceofSQLTransientConnectionException){returnTimeoutType.CONNECTION_POOL;}if(einstanceofTransactionTimedOutException){returnTimeoutType.TRANSACTION;}returnnull;}具体项目还应结合驱动异常层次补充分类。
4.10 时间线必须可观测
一次接口如果能还原成:
0 ms 请求进入 80 ms 获取连接完成 200 ms SQL-1 完成 700 ms 远程调用结束 1600 ms SQL-2 超时 1650 ms 事务标记回滚 1700 ms HTTP 返回开发人员就能快速判断:
连接池不是瓶颈 SQL-1 正常 远程调用占用事务时间过长 SQL-2 触发语句超时 事务随后回滚5. 结果对比
实施前
日志:
ERROR createOrder failed Request processing failedAPM 显示:
接口耗时:3004 ms问题是开发人员无法判断这 3 秒花在哪里。
实施后
结构化日志:
{"traceId":"97cb19c9","poolWaitMs":12,"sqlDurationMs":801,"transactionDurationMs":1102,"timeoutType":"STATEMENT","sqlTemplate":"UPDATE inventory SET available = available - 1 WHERE id = ?","txId":"3d91f14a"}排障可以直接进入决策树:
接口超时 ├─ poolWaitMs 接近 connectionTimeout │ └─ 查连接池、长事务、连接泄漏 ├─ SQL 触发 statement timeout │ └─ 查执行计划、锁等待、索引 ├─ txDurationMs 接近 transaction timeout │ └─ 查事务内远程调用和过多 SQL └─ HTTP 先超时,数据库还在执行 └─ 检查超时层级是否倒挂这比只看一个“请求 3 秒超时”有价值得多。
6. 风险与复盘
6.1 超时不是越短越好
过短的超时会把正常的尾部延迟误判为故障。
生产设置应至少观察:
P50 P95 P99 最大值 锁等待分布 连接池等待分布如果 SQL P99 是 420 ms,把语句超时设成 300 ms,等于主动制造失败。
6.2 超时值不能倒挂
危险配置:
HTTP:1 s SQL:5 s 事务:10 s上游 1 秒就放弃请求,但数据库可能继续执行 4 秒甚至更久。
在高并发下,这会形成:
客户端认为失败 -> 自动重试 -> 原 SQL 仍运行 -> 新请求再次执行 -> 数据库压力继续上升因此在线接口通常应该让内层超时先于外层超时触发。
6.3 事务里不要夹远程调用
典型坏味道:
@Transactionalpublicvoidpay(){updateOrder();callRemotePayment();updatePaymentResult();}远程支付如果耗时 2 秒,数据库连接和事务可能被白白占用 2 秒。
更合理的是缩小事务:
事务 1:落订单状态 远程调用:事务外 事务 2:更新支付结果必要时使用状态机、Outbox 或可靠消息保证最终一致性。
6.4 SQL 超时不代表数据库立即停止
即使客户端发出取消,也不能假设数据库端一定瞬间释放所有资源。
不同数据库和驱动对取消语义实现不同,因此出现大量 SQL 超时时,还应检查:
数据库活动会话 锁等待 正在运行的 SQL 连接状态6.5 重试必须配合幂等
超时后最危险的做法是无脑重试。
因为客户端看到超时,并不总能证明数据库操作没有成功。
例如:
INSERT 已提交 -> 网络响应丢失 -> 客户端超时 -> 客户端重试如果没有唯一键或幂等号,就可能重复下单。
在线接口建议:
超时 + 重试必须同时设计:
幂等键 唯一约束 重试次数 退避策略 异常白名单结语
连接超时、语句超时和事务超时不是三个孤立参数,而是一套从资源层到业务层的保护链。
可以把它们理解成:
连接超时:我能不能及时拿到数据库资源? 语句超时:这一条 SQL 能不能及时完成? 事务超时:这一组业务操作能不能及时完成?对于在线接口,建议优先建立:
连接池等待超时 < SQL 超时 < 事务超时 < HTTP 总超时并把poolWaitMs、sqlDurationMs、transactionDurationMs、timeoutType、traceId、txId同时写入可观测日志。
真正成熟的超时治理,不是让系统“更容易超时”,而是让故障在最接近根因的位置快速失败,并且让异常能够准确地触发回滚、降级、告警和重试策略。
转载自:https://blog.csdn.net/u014727709/article/details/165241209
欢迎 👍点赞✍评论⭐收藏,欢迎指正