简介:接口响应慢、高并发下卡顿阻塞,根源往往不在业务逻辑,而在数据库交互层。本文从事务管控、连接池、预编译语句缓存、批量写入四个维度出发,梳理五种可直接落地的事务优化写法,逐一刻画每种写法的开销来源、改造方式与注意事项,帮助开发者以极小的代码改动换取显著的接口延迟下降。
线上排障时常见一类现象:单机压测并不慢,一到流量高峰接口耗时便快速飙升;业务逻辑反复排查却找不出问题,最后发现瓶颈出在数据库交互层。这类问题通常可以归结到四个维度:
这些问题的共同特点是:单次请求中的开销并不起眼,但在高并发下会被成倍放大。更重要的是,修复成本极低,大多只需要改一个注解属性、调几个配置项、换一种写法。下面按“问题—写法—机理”的顺序逐一展开。
默认的声明式事务是“读写事务”。对一个只查不写的方法而言,这套机制会带来三重冗余:
// ❌ 查询方法使用默认读写事务:快照、脏检查、事务资源一样不少@Transactionalpublic List<Order> listOrders(int pageNo, int pageSize) {return orderRepository.findPage(pageNo, pageSize);}// ✅ 只读事务:仅增加一个属性,执行链路发生四项实质优化@Transactional(readOnly = true)public List<Order> listOrders(int pageNo, int pageSize) {return orderRepository.findPage(pageNo, pageSize);}
加上 readOnly = true 后:
readOnly = true;默认传播级别下,事务方法内部调用另一个事务方法时,后者会加入当前外层事务。这带来两个隐患:
// ❌ 所有逻辑共用一个大事务@Transactionalpublic void createOrder(Order order) {orderRepository.save(order); // 必须在事务内riskClient.check(order); // 远程调用在事务内:连接被长时间占用auditLogRepository.save(buildLog(order)); // 与主流程同事务:主流程回滚则日志消失}// ✅ 主流程保持短事务,非数据库动作移出事务边界@Transactionalpublic void createOrder(Order order) {orderRepository.save(order);eventPublisher.publish(new OrderCreatedEvent(order.getId())); // 提交后异步处理}// 审计日志使用独立新事务,主流程回滚也不影响留痕@Transactional(propagation = Propagation.REQUIRES_NEW)public void recordAuditLog(OrderCreatedEvent event) {auditLogRepository.save(buildLog(event));}
事务里只放数据库操作。 远程调用、消息发送、文件读写、耗时计算,统统移出事务边界;确需与数据一致性挂钩的,用“本地事务 + 事件/消息表”或“事务提交后再触发”的方式补偿,而不是用一个大事务硬扛。
连接池调优的本质,是在“应用侧并发”与“数据库侧承载力”之间找平衡点。
# 语义示例:不同连接池组件的配置项命名略有差异,含义一致pool:max-pool-size: 20 # 最大连接数min-idle: 10 # 最小空闲连接数connection-timeout: 3s # 获取连接超时max-lifetime: 30m # 连接最大存活时间idle-timeout: 5m # 空闲连接回收周期leak-detection-threshold: 60s # 连接泄漏检测阈值
所有参数都没有“标准答案”,必须以自身流量模型压测为准。调参后重点观察获取连接耗时、活跃连接数、数据库侧活跃会话数三项指标的变化趋势。
数据库执行一条 SQL 前,要经过语法解析、语义校验、执行计划生成等步骤。如果每次执行的 SQL 文本都不同,最常见的原因就是把参数拼进了 SQL 字符串,数据库无法复用执行计划,每次都要重新解析,高并发下这部分 CPU 开销非常可观。
// ❌ 拼接 SQL:同结构语句每次文本都不同,缓存永远无法命中String sql = "SELECT * FROM orders WHERE user_id = " + userId;// ✅ 参数化:模板固定,仅替换参数,可被缓存与复用String sql = "SELECT * FROM orders WHERE user_id = ?";
循环里单条保存,是最常见也最隐蔽的反模式。N 条数据意味着 N 次网络往返、N 次语句执行、N 次日志刷盘;单条耗时 1 毫秒,一万条就是十几秒起步,还会长期占用事务与连接。
// ❌ 循环单条写入for (Order o : orders) {orderRepository.save(o);}// ✅ 批量写入 + 分批提交@Transactionalpublic void importOrders(List<Order> orders) {int batchSize = 500;for (int i = 0; i < orders.size(); i += batchSize) {int end = Math.min(i + batchSize, orders.size());orderRepository.saveAll(orders.subList(i, end)); // 需开启批量写支持// 每批结束可清空持久化上下文,避免内存持续增长}}
底层也可直接使用 JDBC 批量接口:
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO orders(user_id, amount, status) VALUES (?, ?, ?)")) {for (Order o : batch) {ps.setLong(1, o.getUserId());ps.setBigDecimal(2, o.getAmount());ps.setInt(3, o.getStatus());ps.addBatch();}ps.executeBatch(); // 整批一次网络往返}
优化不能只靠改代码,还需要度量闭环:
快速检查清单:
readOnly = true?| 写法 | 针对的问题 | 核心动作 |
|---|---|---|
| 只读事务 | 查询承担读写事务开销 | 查询方法统一标注 readOnly = true |
| 事务传播 | 大事务、连带回滚、连接被占 | 合理使用传播级别,事务内只留数据库操作 |
| 连接池调优 | 参数失衡导致排队或压垮数据库 | 容量、超时、泄漏检测成套配置并压测校准 |
| 语句缓存 | 重复硬解析、执行计划无法复用 | 参数化 SQL 配合语句缓存 |
| 批量写入 | 逐条提交、网络往返放大 | 批量接口 + 分批提交 + 主键策略配套 |
事务优化的方法论并不复杂:让查询更“轻”、让事务更“短”、让交互更“少”、让写入更“粗”。五条写法彼此独立,可分批落地;任何一条落地后,都建议用接口耗时与数据库侧指标的前后对比确认收益。把这些细节做扎实,多数“高并发卡顿”问题都能在不改动业务逻辑的前提下得到明显缓解。