先说一个真实经历。周六凌晨两点,线上业务告警,后端所有请求全部超时。翻日志一看,全是org.hibernate.exception.JDBCConnectionException: CannotGetJdbcConnectionException,数据库连接池被打穿,整个服务就像被抽干了水的鱼塘,前端页面全部转圈。最后定位到问题:数据库主库瞬间连接数超过上限,批量任务和线上流量相互叠加,引发了雪崩。复盘时大家都很郁闷——这种故障明明可以在测试阶段就暴露,为什么非要等到线上才炸?答案很简单:因为我们的测试用例从来没“坏”过,数据库永远可用,SQL 永远能查到数据,事务永远能顺利提交。直到真实故障发生时,才发现系统面对数据层故障时几乎没有容错能力。
这就是故障注入测试的价值。所谓故障注入,不是把系统搞挂,而是有预谋地让系统“生病”,然后观察它在生病状态下能不能扛住、能不能恢复、会不会留下后遗症。今天我要聊的是在 Hibernate 这个具体的技术栈里,如何做故障注入测试,以及在实操中要避开的那些坑。
1. 故障注入测试的整体设计:为什么要盯住 Hibernate 这一层
1.1 先搞明白故障注入到底在测什么
很多人一听“故障注入”就以为是要把服务搞崩、搞挂,其实不是。故障注入测的是系统在故障下的“行为”,而不是故障本身。我习惯把它类比成打疫苗:故意让系统接触一小剂量的故障,观察它的免疫反应,然后针对薄弱环节做加固。
具体来说,故障注入要回答四类问题:
- 故障发生时,系统是否抛出了符合预期的异常类型?比如数据库连接断开,上层 API 返回的是 500 还是超时,还是直接把整个线程池拖死。
- 故障发生后,系统能不能在预期时间内恢复?连接池有没有自动重建连接,还是需要人工重启应用。
- 故障期间,资源有没有泄漏?连接有没有归还连接池,线程有没有被释放。
- 故障结束后,数据是否一致?有没有出现半个事务、脏数据、重复提交。
注意,故障注入和混沌工程不是一回事,但很多人混着用。故障注入是“单片药”,目标明确,打一个点,比如让某条 SQL 变慢、让某个连接断开;混沌工程则是“综合性体检”,模拟的是不可预测的分布式系统故障,比如网络分区、节点故障。做 Hibernate 层面的故障注入,我们用的更多是前者,先解决单点问题,再考虑更大的场景。
1.2 Hibernate 是数据库故障的“翻译官”和“放大器”
为什么故障注入要专门盯住 Hibernate 这一层?因为 Hibernate 处在 JDBC 之上、业务之下,数据库层面的故障,在 Hibernate 里会被翻译成各种异常对象。
- 数据库连不上、连接被提前关闭,Hibernate 会抛
JDBCConnectionException或CannotGetJdbcConnectionException。 - 连接池获取不到新连接,会抛
CannotGetJdbcConnectionException。 - SQL 执行超时,会抛
QueryTimeoutException。 - 数据库死锁,会抛
PessimisticLockException。 - 乐观锁版本冲突,会抛
OptimisticLockException或StaleObjectStateException。 - 数据约束冲突,会抛
ConstraintViolationException。
这层“翻译”很重要。如果你在测试时绕过 Hibernate,直接拿 JDBC 去连数据库做故障注入,那你测到的是驱动层面的事,完全无法验证 Hibernate 的 Session 生命周期、一级缓存、延迟加载、批量操作在故障下会变成什么样。比如一个非常典型的例子:事务已经提交,但 Session 还开着,延迟加载的集合在访问时才触发 SQL,此时数据库连接已经不可用,这就会抛LazyInitializationException和底层连接异常混在一起的复杂问题。这些都要通过 Hibernate 这层才能暴露出来。
另一个角度看,Hibernate 也是故障的“放大器”。一级缓存、二级缓存、批量抓取、自动 flush 这些机制,在正常时候是优化,在故障时刻就可能变成灾难。比如批量插入时,Hibernate 会积攒一批操作到最后才 flush,如果这批操作里有一条 SQL 失败,整个事务回滚,之前所有看似成功的行也全部消失。这种“故障被放大”的效应,会让系统表现比直连 JDBC 更复杂、更难以排查,所以在测试阶段就把这些路径摸清楚,非常有必要。
1.3 四个核心故障维度
我做了这么多年稳定性测试,总结下来,Hibernate 应用的故障注入主要集中在四个维度,每个维度对应的注入入口和典型异常都不一样。
| 故障维度 | 典型故障场景 | Hibernate 入口 | 期望观察到的异常 |
|---|---|---|---|
| 连接层 | 数据库宕机、网络断开、连接池耗尽、认证失败 | SessionFactory.openSession()、session.getConnection()、连接池获取连接 | CannotGetJdbcConnectionException、JDBCConnectionException |
| SQL 执行层 | 慢 SQL、锁等待、SQL 语法错误、约束冲突 | session.createQuery().list()、session.save()、session.flush() | QueryTimeoutException、ConstraintViolationException |
| 事务与并发层 | 死锁、乐观锁冲突、事务超时、提交失败 | tx.commit()、tx.rollback()、session.flush() | PessimisticLockException、OptimisticLockException、TransactionException |
| 数据与映射层 | 字段缺失、类型转换错误、时区偏移、序列化失败 | 实体映射、类型转换、二级缓存读取 | PropertyAccessException、DataException |
这张表建议收藏。做测试设计的时候,先想清楚要测哪个维度,再决定从哪个入口注入,最后用表格里的异常类型作为断言依据。下面讲的五种注入手段,基本都会落到这四个维度上。
2. 五大主流故障注入手段与代码实操
2.1 手段一:自定义 ConnectionProvider,精确制造连接故障
先介绍我用的最多、也最容易掌控的手段:自定义ConnectionProvider。
Hibernate 从 4.0 开始就把连接获取的细节抽象成了org.hibernate.engine.jdbc.connections.spi.ConnectionProvider接口。正常情况下,我们不需要关心这个接口,Hibernate 会通过配置文件里的hibernate.connection.provider_class来决定用哪个实现,比如DruidConnectionProvider、C3P0ConnectionProvider或者HikariCPConnectionProvider的适配器。但如果我们要做故障注入,就可以在这里做手脚。
思路很简单:写一个包装类,持有真正干活的那个ConnectionProvider,然后在getConnection()方法里,根据开关决定是否注入故障。
public class FaultInjectingConnectionProvider implements ConnectionProvider { private final ConnectionProvider delegate; private final AtomicBoolean failNext = new AtomicBoolean(false); private final AtomicLong delayMillis = new AtomicLong(0L); public FaultInjectingConnectionProvider(ConnectionProvider delegate) { this.delegate = delegate; } /** 让下一次 getConnection() 抛异常 */ public void setFailNext(boolean fail) { failNext.set(fail); } /** 给下一次 getConnection() 加延迟,单位毫秒 */ public void setDelayMillis(long delay) { delayMillis.set(delay); } @Override public Connection getConnection() throws SQLException { if (failNext.compareAndSet(true, false)) { throw new SQLException("Injected connection failure"); } long delay = delayMillis.get(); if (delay > 0) { try { Thread.sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } return delegate.getConnection(); } @Override public void closeConnection(Connection conn) throws SQLException { delegate.closeConnection(conn); } @Override public boolean supportsAggressiveRelease() { return delegate.supportsAggressiveRelease(); } @Override @SuppressWarnings("rawtypes") public boolean isUnwrappableAs(Class unwrapType) { return delegate.isUnwrappableAs(unwrapType); } @Override @SuppressWarnings("unchecked") public <T> T unwrap(Class<T> unwrapType) { return delegate.unwrap(unwrapType); } }配置方式是在hibernate.cfg.xml里指定:
<property name="hibernate.connection.provider_class"> com.example.fault.FaultInjectingConnectionProvider </property>这里有一个坑必须提醒:Hibernate 5 和 Hibernate 6 的ConnectionProvider接口定义有差异。上面这段代码对应的接口在 Hibernate 5.x 里是isUnwrappableAs和unwrap,到 Hibernate 6.x 变成了isUnwrappableAs(Class<?> unwrapType),方法签名泛型略有调整。如果你用的是 Hibernate 6,编译时需要对照实际接口调整一下。
自定义 ConnectionProvider 的好处是能精确控制故障注入的触发时机。我通常在测试类里持有一个FaultInjectingConnectionProvider的静态实例,然后通过一个静态方法随时开关故障。这样在集成测试里,就可以很自然地写出“先打开故障开关,然后调用业务方法,断言异常,关闭开关”这种顺序。
2.2 手段二:用 JDBC 动态代理拦截 Connection 和 SQL
自定义 ConnectionProvider 只能模拟连接获取的问题。如果想把故障注入点下放到 SQL 执行层,比如让某条 SQL 执行失败、让某条 SQL 变慢、甚至篡改 SQL 语句,就需要用动态代理包装 JDBC 层的Connection、Statement。
原理上,就是利用 Java 动态代理,在Connection.prepareStatement()、Statement.executeQuery()、PreparedStatement.executeUpdate()这些方法上做手脚。给一个最简示例:
public class FaultInjectingConnectionHandler implements InvocationHandler { private final Connection target; private final AtomicBoolean failNextQuery = new AtomicBoolean(false); public FaultInjectingConnectionHandler(Connection target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String name = method.getName(); // 在 prepareStatement 时注入故障 if (name.equals("prepareStatement") && failNextQuery.compareAndSet(true, false)) { throw new SQLException("Injected SQL failure"); } return method.invoke(target, args); } public static Connection wrap(Connection target) { return (Connection) Proxy.newProxyInstance( connectionClassLoader(), new Class[]{Connection.class}, new FaultInjectingConnectionHandler(target)); } }不过说句实话,生产环境里我不太推荐自己写动态代理去包装 JDBC 连接。因为 JDBC 接口方法非常多,而且Connection的 unwrap、close、isClosed 这些方法一旦处理不好,会把连接池的内部状态搞乱,出现各种莫名其妙的“连接泄漏”。
更稳妥的做法是:用现成的工具,在网络层做代理。这里我要重点推荐Toxiproxy,一个由 Shopify 开源的网络故障注入工具。Toxiproxy 可以放在应用和数据库之间,通过有毒药(toxic)的方式模拟延迟、断开、带宽限制、超时等。用法特别简单:
# 创建数据库代理 toxiproxy-cli create -l localhost:13306 -u localhost:3306 mysql_proxy # 给代理加一个延迟毒药,延迟 3000ms toxiproxy-cli toxic add -t latency -a latency=3000 mysql_proxy # 加一个断开连接毒药 toxiproxy-cli toxic add -t disconnect mysql_proxy # 移除所有毒药 toxiproxy-cli toxic remove -t latency mysql_proxy用 Toxiproxy 的好处是,应用的 JDBC 连接串只需要指向localhost:13306,代码里什么都不用改。故障注入和故障恢复都是动态的,非常适合做自动化测试。在 Hibernate 的故障注入测试里,Toxiproxy 解决的是网络层和 SQL 执行层的故障注入,和自定义 ConnectionProvider 正好互补。
2.3 手段三:用 Hibernate 拦截器和事件监听器做细粒度注入
Hibernate 提供了一套内部的拦截器(Interceptor)和事件监听器(EventListener)机制,这是做故障注入的一把好手,但很多人不太了解。
先说Interceptor。在 Hibernate 5.x 里,org.hibernate.Interceptor接口有一些方法可以覆盖 SQL 执行过程,最常用的是onPrepareStatement(String sql)。这个方法在 Hibernate 准备执行一条 SQL 之前回调,你可以修改 SQL,也可以在这里直接抛异常:
public class FaultInjectingInterceptor extends EmptyInterceptor { private volatile boolean failOnSql = false; @Override public String onPrepareStatement(String sql) { if (failOnSql && sql.toUpperCase().contains("FROM ORDERS")) { // 改写成一条不存在的表,触发 SQL 执行错误 throw new RuntimeException("Injected SQL failure on ORDERS"); } return sql; } public void setFailOnSql(boolean fail) { this.failOnSql = fail; } }注册Interceptor有两种方式,一种是在hibernate.cfg.xml里配置hibernate.ejb.interceptor(Hibernate 5 旧写法)或hibernate.session_factory.interceptor(新写法),另一种是代码里配置:
Configuration cfg = new Configuration().configure(); cfg.setInterceptor(new FaultInjectingInterceptor());再说事件监听器。Hibernate 支持一整套实体生命周期事件,比如PreInsertEvent、PostInsertEvent、PreUpdateEvent、PreDeleteEvent、LoadEvent等。我经常在测试里用PreInsertEventListener,因为它的onPreInsert方法有一个特殊设计:返回true会取消这次插入操作,返回false则继续执行。基于这个特性,我们可以做出非常细粒度的注入:
public class FaultInjectingPreInsertListener implements PreInsertEventListener { private final AtomicBoolean failOnUserInsert = new AtomicBoolean(false); @Override public boolean onPreInsert(PreInsertEvent event) { if (failOnUserInsert.compareAndSet(true, false)) { // 这里只能取消操作,不能直接抛异常 return true; } return false; } }不过要注意,事件监听器接口里的onPreInsert方法签名上没有声明throws Exception,所以你不能直接抛受检异常。要制造故障,要么返回true取消操作,要么在方法里抛RuntimeException。返回true的话,Hibernate 会认为插入被中止,但不会回滚整个事务,这种“静默失败”非常适合测试某些业务逻辑是否做了存在性校验。抛RuntimeException则会触发事务回滚,适合测试事务的原子性。
事件监听器的注册有两种方式。一种是编码注册:
SessionFactoryImpl sessionFactory = (SessionFactoryImpl) sessionFactory; sessionFactory.getServiceRegistry() .getService(EventListenerRegistry.class) .getEventListenerGroup(EventType.PRE_INSERT) .appendListener(new FaultInjectingPreInsertListener());另一种是配置文件,用hibernate.ejb.event.pre-insert指定监听器全类名,这个在 Hibernate 5 里是支持的。提醒一点,Hibernate 6 的事件监听器注册 API 和 5 不太一样,编译前要确认下依赖版本。
拦截器和监听器的好处是,故障注入点非常贴近业务代码。如果你要测的是“当订单插入失败时,优惠券是否会被回滚”这类业务级问题,用这种方式最合适。
2.4 手段四:数据库层故障注入
动态代理和拦截器,都是“应用内部”做手脚。还有一种思路,是直接在数据库这一侧制造故障。这种方式的优势是真实性高,因为数据库是真宕机、真忙、真断网,故障表现和线上完全一致。
我在实际项目里主要用三种工具:
第一种是 Testcontainers。在集成测试里用容器起一个 MySQL/PostgreSQL 实例,测试过程中动态停掉容器或暂停容器。Testcontainers 的GenericContainer提供了getDockerClient()方法,可以拿到 Docker 客户端,直接对容器执行 stop、start、pause、unpause 操作:
@Testcontainers public class DatabaseFaultTest { @Container static GenericContainer<?> mysql = new GenericContainer<>("mysql:8.0") .withEnv("MYSQL_ROOT_PASSWORD", "test") .withExposedPorts(3306); @Test void testDatabaseRestart() throws Exception { DockerClient dockerClient = mysql.getDockerClient(); String containerId = mysql.getContainerId(); // 模拟数据库宕机 dockerClient.stopContainerCmd(containerId).exec(); // 这里执行业务逻辑,断言返回预期异常 assertThrows(JDBCConnectionException.class, () -> { userService.getUserById(1L); }); // 重启数据库 dockerClient.startContainerCmd(containerId).exec(); // 等待端口就绪 Thread.sleep(5000); // 断言恢复后业务正常 assertNotNull(userService.getUserById(1L)); } }第二种是 Docker pause/unpause。docker pause是冻结容器里的所有进程,这种故障在表现上很像数据库被“卡住”,进程还在,但不响应任何请求。很多慢 SQL 和锁等待场景可以用 pause 模拟,比直接 stop 更贴近“数据库假死”的线上故障。
第三种是 ChaosBlade,阿里开源的混沌工程工具。它支持对 MySQL 直接注入故障,比如延迟、丢包、SQL 报错:
# 注入 3000ms 的 MySQL 延迟 blade create mysql delay --time 3000 --offset 1000 --port 3306 # 注入 MySQL 抛错 blade create mysql throw --exception "org.springframework.dao.DataAccessResourceFailureException" --port 3306 # 销毁实验 blade destroyChaosBlade 适合在测试环境或者灰度环境直接对真实数据库实例操作。它的优势是故障类型丰富,而且有命令行和 HTTP API,方便集成到自动化脚本里。缺点是要在被注入的机器上装 agent,在纯容器化环境里部署会稍微费点功夫。
2.5 手段五:事务与并发故障注入
连接、SQL 都覆盖了,还有一个很容易被测漏的维度:并发。Hibernate 应用里最常见的并发故障是死锁和乐观锁冲突,这些都不是单线程能触发出来的,需要精心编排多线程执行顺序。
先讲死锁。死锁的制造逻辑很简单:两个事务各自持有一把锁,同时等着对方释放。在 MySQL InnoDB 里,行锁是最常见的死锁来源。下面是我在测试里经常用的死锁构造方式:
ExecutorService pool = Executors.newFixedThreadPool(2); // 事务1:先更新 A,再更新 B pool.submit(() -> { Session s1 = sessionFactory.openSession(); s1.beginTransaction(); Account a1 = s1.get(Account.class, 1L); a1.setBalance(a1.getBalance() - 10); s1.flush(); // 先持有 A 的锁 Thread.sleep(100); // 等待事务2 持有 B 的锁 Account a2 = s1.get(Account.class, 2L); a2.setBalance(a2.getBalance() - 10); s1.getTransaction().commit(); s1.close(); }); // 事务2:先更新 B,再更新 A pool.submit(() -> { Session s2 = sessionFactory.openSession(); s2.beginTransaction(); Account b2 = s2.get(Account.class, 2L); b2.setBalance(b2.getBalance() - 10); s2.flush(); // 先持有 B 的锁 Account b1 = s2.get(Account.class, 1L); b1.setBalance(b1.getBalance() - 10); s2.getTransaction().commit(); s2.close(); });几个需要强调的细节:
- 两个事务必须在独立的 Session、独立的线程里执行。如果在同一个线程里串行开两个 Session,前一个事务提交后锁就释放了,根本死锁不了。
- 插入
Thread.sleep(100)并不是为了“必现死锁”,而是为了尽量让两个事务真的在交叉时间点去获取对方的锁。没有这个等待,两个事务可能太快,其中一个已经提交,另一个还没开始抢锁。 - 死锁发生后,InnoDB 会检测到并主动回滚其中一个事务,抛
PessimisticLockException;另一个事务是否能正常提交,取决于它的 SQL 是否已经执行。测试断言时,应该断言“两个事务不能同时成功,且至少一个抛出 PessimisticLockException”。
乐观锁冲突的构造稍微简单一点。Hibernate 用@Version字段做乐观锁,两个线程读同一个实体,都修改,后提交的那个会抛OptimisticLockException或StaleObjectStateException(Hibernate 5 里是org.hibernate.StaleObjectStateException)。测试里要注意,Hibernate 是在 flush 或 commit 时检查版本号的,所以断言时机要放在tx.commit()或者session.flush()之后。
事务超时的注入,可以通过 Session 的setTimeout来设置事务级超时时间,或者用数据库驱动层的超时。Hibernate 里可以在代码中设置:
Session session = sessionFactory.openSession(); session.beginTransaction(); session.getTransaction().setTimeout(2); // 2秒超时如果 SQL 执行超过 2 秒,Hibernate 会抛QueryTimeoutException。这个做法适合测试那些会长时间持有锁或者执行大查询的业务逻辑。
3. 典型故障场景实测记录
3.1 场景一:数据库宕机后,连接池多久才能恢复
某次测试里,我用 Testcontainers 起了 MySQL 8.0,应用连接池用的 HikariCP,maximumPoolSize=10,connectionTimeout=30000。测试流程是这样:
- 先跑一个正常查询,确认应用工作正常。
- 用 Docker 命令 stop 掉 MySQL 容器。
- 立刻发起一个查询,观察异常类型。
- 等待 10 秒后启动 MySQL 容器。
- 再发起查询,观察是否恢复。
实测结果很有代表性。在数据库宕机瞬间,应用并不会立刻感知到故障,而是等到下一次真正的连接请求到达时,连接池里的旧连接已经失效,Hibernate 尝试用它执行 SQL 时,发现连接已关闭,于是抛JDBCConnectionException。这个过程的耗时取决于 HikariCP 的connectionTimeout参数和 MySQL 驱动检测断连的机制,实测大约在 5 到 15 秒之间。
恢复阶段更值得关注。MySQL 容器启动后,HikariCP 并不会自动立刻把连接池填满,而是懒惰地逐个创建新连接。如果你的连接池minimumIdle配得比较大,恢复会比较快,但这个过程也不是瞬间完成的。实测中,容器起来后大约 3 到 4 秒,应用才恢复可用。
这个场景带给我两个教训:
- 连接池的
connectionTimeout不能配太长,否则数据库故障时,上层服务会长时间阻塞,把整个线程池拖死。 - 测试断言时不要一 stop 容器就断言“立刻抛异常”,因为连接池可能还有空闲连接,第一次请求还在“蒙在鼓里”。最好先让一个请求触发连接建立,再发起第二个请求去观察异常。
3.2 场景二:慢 SQL 把连接池打满之后
慢 SQL 的故障注入用 Toxiproxy 做最方便。我在 MySQL 前面挂了 Toxiproxy,给数据库代理加了 3000ms 延迟。应用接口一个简单的分页查询,连接池大小固定 10,我用 50 个并发线程同时请求这个接口。
观察到的现象分成三个阶段:
- 第一阶段:最初的 10 个请求进入了执行阶段,各自占着一个数据库连接,阻塞在慢 SQL 上。
- 第二阶段:剩下的 40 个请求全部排队等待获取连接,应用线程被阻塞在连接池的获取操作上。
- 第三阶段:
connectionTimeout=30000到了 30 秒后,排队请求开始抛CannotGetJdbcConnectionException。
注意,慢 SQL 本身并没有让应用直接失败,它只是让应用“看起来很忙”,但真正的灾难是连接池耗尽后,那些本可以快速完成的业务请求也跟着遭殃。这个测试强烈建议在预发环境做一次,因为它的结论会直接影响你对连接池参数的设定。
| 参数 | 建议值 | 理由 |
|---|---|---|
maximumPoolSize | 根据 QPS 和单 SQL 耗时估算 | 不能盲目配大,否则数据库本身会被打挂 |
connectionTimeout | 3000~5000ms | 超过这个时间直接失败,不要无限等 |
validationTimeout | 1000ms | 拿连接时校验连接可用性的最短超时 |
idleTimeout | 600000ms | 空闲连接回收时间,太长会占用数据库连接 |
3.3 场景三:老项目 c3p0 连接池泄漏模拟
现在聊一个和热词“struts2 hibernate c3p0”直接相关的场景。我碰到过好些 2015 年左右的老项目,技术栈是 Struts2 + Hibernate 3/4 + c3p0。这类老项目里最常见的隐患就是连接泄漏。
连接泄漏的测试方式其实特别粗暴:在一个循环里不断打开 Session、做查询、然后故意不关闭 Session。跑一段时间后,观察连接池还能不能正常分配连接。
for (int i = 0; i < 100; i++) { Session session = sessionFactory.openSession(); User user = session.get(User.class, 1L); System.out.println(user.getName()); // 故意不 close() }跑完这段代码,c3p0 的连接池会慢慢被耗尽。默认情况下,c3p0 的maxPoolSize到了上限后,新的请求会等checkoutTimeout,如果配置为 0,线程就会无限等待,应用直接挂死。老项目经常出事,就是checkoutTimeout没配或配成了 0。
c3p0 的经典救火配置,我在以前的项目里是这么调的:
<property name="hibernate.c3p0.acquireIncrement">1</property> <property name="hibernate.c3p0.idleTestPeriod">300</property> <property name="hibernate.c3p0.maxSize">20</property> <property name="hibernate.c3p0.minSize">5</property> <property name="hibernate.c3p0.maxStatements">0</property> <property name="hibernate.c3p0.timeout">1800</property> <property name="hibernate.c3p0.acquireRetryAttempts">3</property> <property name="hibernate.c3p0.acquireRetryDelay">1000</property> <property name="hibernate.c3p0.checkoutTimeout">5000</property> <property name="hibernate.c3p0.unreturnedConnectionTimeout">30</property>重点说两个容易被忽略的参数:
unreturnedConnectionTimeout:如果一个连接被借走超过这个秒数还没归还,c3p0 会强制回收。这个参数能在连接泄漏时兜底,但也可能误杀长事务,所以要根据业务实际耗时来设置。checkoutTimeout:从连接池获取连接的等待上限,单位毫秒。设成 5000 的意思是,如果 5 秒内拿不到连接,直接抛异常,应用快速失败,而不是无限卡死。
在故障注入测试里,故意不关 Session 的做法,配合unreturnedConnectionTimeout,可以很好地验证“即使代码写漏了,连接池也不会把系统拖死”。当然,这只是“兜底”,真正的修复还是要排查出代码里哪些地方 open 了 Session 没 close。
3.4 场景四:夏令时切换引发的日期异常
热词“hibernate日期夏令时报错”的呼声一直不低,我也恰好在一次测试里踩过这个坑。这个问题的本质不是 Hibernate 本身,而是 JVM 时区、JDBC 连接时区、数据库 session 时区三者不一致,在夏令时切换的时间点引发了时间偏移。
故障注入的方式很有意思,不需要真的等到 3 月第二个周日或者 11 月第一个周日,只需要在测试环境里故意把 JVM 时区和数据库时区设成不一致。
比如数据库服务器时区设置为America/New_York,JVM 启动参数用-Duser.timezone=Asia/Shanghai,JDBC 连接串里又加了serverTimezone=UTC,这三处一旦打架,读出来的时间就会出现偏差。
连接串: jdbc:mysql://localhost:3306/test?serverTimezone=UTC JVM: -Duser.timezone=America/New_York MySQL: SET GLOBAL time_zone = 'America/New_York';当数据库里存储的是一个“夏令时切换当天不存在”的本地时间时,比如2024-03-10 02:30:00(这个时刻在America/New_York时区里因为夏令时跳转而根本不存在),数据库驱动在解析时表现各异,轻则时间偏差一小时,重则直接抛解析异常。
Hibernate 从 5.2.11 开始提供了hibernate.jdbc.time_zone属性,可以统一指定 JDBC 连接使用的时区。如果你负责的应用有跨时区使用的场景,我建议在集成测试里明确配置这个属性,并专门加一个“时区一致性”测试。测试方法可以这样:
- 用
ZoneId明确指定 JVM 的时区,模拟不同地区的用户。 - 插入一条时间记录,然后再读出来,断言时间偏移量不超过预期。
- 重点测试夏令时切换日的前后一天,观察日期字段是否出现 1 小时偏移。
这个场景现在很多团队还不重视,但一旦出问题,用户侧的订单时间、账单时间、日志时间全是错的,而且极难排查。提前做故障注入,比事后救火划算得多。
4. 故障注入测试的工程化落地
4.1 用 Testcontainers 编排数据库故障
如果故障注入只做一次“手工实验”,价值有限。要想真正进入日常回归,就必须工程化。我目前最顺手的组合是 JUnit 5 + Testcontainers + 一个故障编排基类。
先说 Testcontainers。它的最大优势,是不需要开发环境里额外搭数据库。测试时临时启动一个干净的 MySQL/PostgreSQL 容器,测试结束自动销毁。配合前面的 Docker 命令,就能在测试代码里动态模拟数据库停机。
一个可以直接套用的测试骨架长这样:
public abstract class DatabaseFaultInjectionTestBase { protected static GenericContainer<?> database; @BeforeAll static void startDatabase() { database = new GenericContainer<>("mysql:8.0") .withEnv("MYSQL_ROOT_PASSWORD", "test") .withExposedPorts(3306) .waitingFor(Wait.forListeningPort()); database.start(); } protected void stopDatabase() { database.getDockerClient().pauseContainerCmd(database.getContainerId()).exec(); } protected void resumeDatabase() { database.getDockerClient().unpauseContainerCmd(database.getContainerId()).exec(); } protected String getJdbcUrl() { return "jdbc:mysql://" + database.getHost() + ":" + database.getMappedPort(3306) + "/test?useSSL=false&serverTimezone=UTC"; } }子类继承这个基类,就可以在具体的测试方法里随心所欲地停库、恢复库、断言异常。有一点我要特别提醒:pauseContainerCmd和stopContainerCmd效果不一样。pause是冻结进程,连接不会立刻断开,更像“数据库假死”;stop是直接关掉进程,连接会被对端强制关闭。两个都要测,它们的故障表现完全不同。
4.2 测试用例设计与断言要点
故障注入测试的断言,不能只断言“程序没崩”,要真正断言“程序按预期的方式失败了”。我总结了一个断言要点清单,写进团队测试规范里:
- 异常类型必须精确匹配。比如
CannotGetJdbcConnectionException和QueryTimeoutException是完全不同的故障,断言时要用assertThrows指定具体类型,而不是笼统地 catchException。 - 故障期间,系统响应时间应该在可接受范围内。比如你配置了连接获取超时 5 秒,那么测试就应该断言请求在 5 到 7 秒内返回失败,而不是无限阻塞。
- 故障恢复后,后续请求必须 100% 成功。这能验证连接池是否有自我修复能力。
- 事务边界要干净。故障发生时,不能出现“数据只插了一半”的情况。
- 资源释放要到位。故障注入后,连接池的活动连接数必须归位,不能只增不减。
我习惯用一个表格来组织测试用例,每一条用例对应一个故障场景。
| 测试场景 | 注入方式 | 观察指标 | 通过标准 |
|---|---|---|---|
| 数据库宕机 | Docker stop 容器 | 异常类型、恢复时间、连接池活动连接数 | 抛JDBCConnectionException,恢复后 10s 内请求成功 |
| 数据库假死 | Docker pause 容器 | 超时时间、线程阻塞情况 | 5s 内返回超时错误,线程池无堆积 |
| 慢 SQL | Toxiproxy 加 3000ms 延迟 | SQL 耗时、连接池占用 | 触发连接池耗尽兜底,无永久阻塞 |
| 死锁 | 两个事务按相反顺序更新 | 异常类型、事务结果 | 至少一个事务抛PessimisticLockException |
| 乐观锁冲突 | 两个线程并发修改同一实体 | 版本号变化、异常类型 | 后提交事务抛OptimisticLockException |
5. 常见问题与排查技巧实录
做故障注入测试时,有些问题属于“故障注入的故障”,不是被测系统的问题,而是测试工具和测试代码自身的问题。我整理了几个高频的坑。
问题一:故障注入后,连接池长时间不恢复。表现是,数据库已经重新启动,连接池里却还是一堆坏连接,应用恢复需要很长时间。排查思路:检查连接池是否配置了连接校验。HikariCP 默认有connectionTestQuery或jdbc4的校验机制,c3p0 则有automaticTestTable和idleConnectionTestPeriod。如果这些没配好,连接池可能一直拿着“僵尸连接”。技巧是,故障注入结束后,可以通过连接池的监控面板观察连接创建时间,确认新连接是否在持续创建。
问题二:死锁注入导致整个测试进程卡死。有时候死锁注入不生效,线程一一直阻塞在获取锁上,测试停不下来。我遇到过,原因是事务隔离级别或者索引顺序导致的锁行为不一致。排查方向:确认两个事务更新的是同一批行,确认更新条件能命中索引(否则行锁会退化成表锁,表现完全不同)。另外,给测试线程加上超时中断机制,防止死锁未检测到时测试卡死。
问题三:Hibernate 版本不同,故障注入 API 对不上。Hibernate 5 的Interceptor用了onPrepareStatement,Hibernate 6 变成了onPrepareStatement(String sql, ParseContext parseContext),事件监听器的注册方式也变了。建议项目里统一了 Hibernate 版本后,再做故障注入组件,不要直接拷贝网上代码。
问题四:懒加载异常和真实故障混在一起。故障注入时,如果 Session 还开着,延迟加载集合的 SQL 在连接断开后执行,抛出的异常是LazyInitializationException还是JDBCConnectionException,取决于事务边界的设计。这个现象本身就有测试价值——它暴露了你的事务边界是否合理。但如果要单独验证某一种故障,测试代码里最好先关闭懒加载特性(比如用 fetch join),让故障注入的目标更单一。
问题五:c3p0 参数没生效。老项目里经常出现改了hibernate.c3p0.*配置但连接池行为没变化的情况。大概率是 JAR 包没引入 c3p0 的 Hibernate 集成包,或者配置项的 key 写错了。Hibernate 5 里 c3p0 的配置项必须以hibernate.c3p0.开头,而且必须在SessionFactory构建前加载。排查时,先看连接池实际类的全限定名,确认是不是真的在用 c3p0。
我在做故障注入测试时最深的一个体会是