一、故障初现:Spanner 突然变慢的诡异现象
前阵子我负责的业务系统突然出了个怪问题:每天上午刚上班那会,业务响应速度会突然变慢,大概持续1-2小时后又自动恢复。一开始大家都以为是早上用户量上来了,属于正常波动,但连续一周都是这样,而且慢的时候连最简单的查询都要等几秒,比平时慢了几十倍,这肯定不正常。
我们先查了Spanner的监控,发现慢的时候,Spanner的CPU、内存、磁盘IO这些核心指标都很正常,甚至比高峰时还低。这就更奇怪了——资源没占满,为啥会慢?后来我们发现一个细节:慢的时候,Spanner的“活跃连接数”指标特别高,比正常时翻了好几倍,而且降不下来,要等1-2小时才会慢慢掉回正常水平,和业务变慢的时间完全对应上了。
二、问题定位:连接泄漏的本质
我们先理清楚Spanner的连接逻辑:应用要和Spanner交互,得先建立连接,用完之后要及时关闭,不然这个连接就会一直占着Spanner的资源。Spanner本身对连接数是有限制的,超过限制的话,新的连接请求要么排队,要么直接失败,就算没到限制,太多空闲连接也会拖慢整体性能。
那为啥连接会一直占着不释放?最常见的原因就是“连接泄漏”——应用代码里,拿到连接之后,因为各种原因没关,或者关的逻辑有问题,导致连接一直没被回收。
我们用的是Java写的应用,和Spanner交互用的是官方的客户端库。一开始我们怀疑是Spanner客户端的连接池配置有问题,比如最大连接数设得太高,或者空闲超时时间太长。但查了配置之后发现,最大连接数设的是20,空闲超时是5分钟,按道理5分钟不用的连接应该会被自动回收,不可能连续占1-2小时。
那问题肯定出在代码里。我们找了一段最常用的Spanner查询代码,简化后是这样的:
// 技术栈:Java 11 + Google Cloud Spanner Java Client Library
import com.google.cloud.spanner.DatabaseClient;
import com.google.cloud.spanner.ResultSet;
import com.google.cloud.spanner.Statement;
public class SpannerService {
private DatabaseClient databaseClient;
// 构造函数,初始化Spanner客户端
public SpannerService(DatabaseClient databaseClient) {
this.databaseClient = databaseClient;
}
// 查询用户信息的方法
public User queryUser(String userId) {
// 从客户端获取一个连接(本质是获取一个ResultSet,ResultSet关联着底层的连接)
ResultSet resultSet = databaseClient.singleUse().executeQuery(Statement.of("SELECT id, name, email FROM users WHERE id = @id").bind("id").to(userId));
// 遍历结果集
if (resultSet.next()) {
User user = new User();
user.setId(resultSet.getString("id"));
user.setName(resultSet.getString("name"));
user.setEmail(resultSet.getString("email"));
return user;
}
// 问题就在这里:没有关闭ResultSet!
return null;
}
}
这段代码看起来没毛病,但问题就出在ResultSet没关。Spanner的ResultSet是和底层的TCP连接绑定的,只有当ResultSet被显式关闭,或者被垃圾回收(GC)的时候,底层的连接才会被释放回连接池。
那为啥早上慢,下午又好?因为早上刚上班,用户开始访问,大量调用这个方法,每个调用都拿到一个连接但不关闭,连接池里的连接很快被占满,新的请求只能排队,所以变慢。而Java的GC是不定时触发的,当连接占满后,这些没被关闭的ResultSet会慢慢变成垃圾,GC触发的时候,会把这些ResultSet回收,底层的连接也就释放了,所以过1-2小时后性能又恢复了。
为了验证这个猜想,我们做了个小测试:写一个循环调用queryUser方法的程序,跑了10分钟后,看Spanner的活跃连接数,果然一直在涨,涨到20之后就不涨了(因为我们设的最大连接数是20),但程序后续的请求开始排队,响应时间变长。
三、根因分析:连接泄漏的深层原因
找到了问题代码,我们再深挖一下,为什么会出现这种情况?
- 开发认知不足:很多开发不知道Spanner的ResultSet需要显式关闭,以为用完结果集就没事了。甚至有些开发以为连接池会自动回收,其实连接池回收的前提是上层的ResultSet被释放。
- 异常处理逻辑缺失:就算知道要关闭ResultSet,也经常会忘了在finally块里关闭。比如如果查询过程中抛了异常,代码直接返回,根本没机会执行关闭的代码。
- 工具链不完善:没有静态代码检查工具能检测出ResultSet没关闭的问题,也没有监控能直接定位到是哪个接口导致的连接泄漏。
四、修复方案:从代码到配置的全面优化
4.1 代码层面:规范连接和结果集的关闭
最直接的修复就是在代码里显式关闭ResultSet,而且要保证不管有没有异常,都能关闭。Java里最常用的方式就是用try-with-resources语句,这个语句会自动关闭实现了AutoCloseable接口的资源,ResultSet正好实现了这个接口。
修复后的代码如下:
// 技术栈:Java 11 + Google Cloud Spanner Java Client Library
import com.google.cloud.spanner.DatabaseClient;
import com.google.cloud.spanner.ResultSet;
import com.google.cloud.spanner.Statement;
public class SpannerService {
private DatabaseClient databaseClient;
public SpannerService(DatabaseClient databaseClient) {
this.databaseClient = databaseClient;
}
public User queryUser(String userId) {
// 用try-with-resources包裹ResultSet,会自动关闭
try (ResultSet resultSet = databaseClient.singleUse().executeQuery(Statement.of("SELECT id, name, email FROM users WHERE id = @id").bind("id").to(userId))) {
if (resultSet.next()) {
User user = new User();
user.setId(resultSet.getString("id"));
user.setName(resultSet.getString("name"));
user.setEmail(resultSet.getString("email"));
return user;
}
return null;
} catch (Exception e) {
// 异常处理,比如打日志
e.printStackTrace();
return null;
}
}
}
修改后,我们再跑之前的测试程序,发现Spanner的活跃连接数一直稳定在2左右,不会再涨了,响应时间也正常。
4.2 配置层面:优化连接池参数
除了代码修复,我们还优化了连接池的配置,增加了一道保险。比如把最大连接数从20调整到50(根据业务的并发量合理设置),把空闲超时时间从5分钟调整到2分钟,这样就算有少量连接泄漏,也能更快被回收。
连接池的配置示例:
// 技术栈:Java 11 + Google Cloud Spanner Java Client Library
import com.google.cloud.spanner.SpannerOptions;
public class SpannerConfig {
public SpannerOptions getSpannerOptions() {
return SpannerOptions.newBuilder()
// 设置最大连接数
.setMaxChannels(50)
// 设置空闲超时时间(单位:毫秒),2分钟
.setIdleTimeoutMillis(120000)
.build();
}
}
4.3 监控层面:增加连接泄漏的预警
为了避免以后再出现类似问题,我们在监控系统里增加了两个指标:
- 应用层面:每个接口的ResultSet关闭率,低于100%就报警。
- Spanner层面:活跃连接数的增长率,短时间内快速增长就报警。
五、方案验证与效果
修复后,我们连续观察了一周,每天上午的业务变慢现象完全消失了,Spanner的活跃连接数一直稳定在正常范围,响应时间也回到了之前的水平。
我们还做了一个压力测试:模拟100个并发请求,调用queryUser接口,跑了1小时,Spanner的活跃连接数最高到了15,远低于我们设的最大连接数50,响应时间稳定在100毫秒以内,没有出现排队的情况。
六、总结与注意事项
6.1 应用场景
这次的问题属于典型的“中间件客户端连接泄漏”场景,除了Spanner,其他中间件比如MySQL、Redis、MongoDB等都可能出现类似问题。尤其是当中间件的客户端库对连接的管理逻辑比较复杂,开发人员对其底层原理不熟悉时,很容易出现连接泄漏。
6.2 技术优缺点
- 连接池的优点:复用连接,减少建立连接的开销,提高性能;
- 连接池的缺点:如果配置不当或者代码有泄漏,会导致连接占满,影响整体性能;
- try-with-resources的优点:自动关闭资源,避免手动关闭的遗漏,代码更简洁;
- try-with-resources的缺点:只适用于实现了AutoCloseable接口的资源,不是所有资源都支持。
6.3 注意事项
- 熟悉中间件客户端的原理:在使用任何中间件的客户端库时,一定要搞清楚连接的生命周期,哪些资源需要手动关闭,哪些会自动关闭。
- 规范代码写法:对于需要手动关闭的资源,一定要用try-with-resources或者finally块来保证关闭。
- 合理配置连接池:连接池的参数不是越大越好,要根据业务的并发量来设置,最大连接数不能超过中间件本身的限制,空闲超时时间也不能太长。
- 增加监控和预警:不仅要监控中间件的核心指标,还要监控应用层面的连接使用情况,及时发现问题。
Comments